jev-agent-tools 0.2.0 → 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 (117) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +22 -14
  4. package/SECURITY.md +17 -1
  5. package/dist/adapters/ask-files.js +11 -2
  6. package/dist/adapters/ask-proof.js +63 -7
  7. package/dist/adapters/command.js +82 -29
  8. package/dist/adapters/docs.js +30 -10
  9. package/dist/adapters/evidence-context.js +119 -0
  10. package/dist/adapters/files.js +141 -16
  11. package/dist/adapters/find.js +34 -6
  12. package/dist/adapters/git-base.js +7 -1
  13. package/dist/adapters/git.js +51 -7
  14. package/dist/adapters/locate-file.js +47 -9
  15. package/dist/adapters/private-storage.js +14 -6
  16. package/dist/adapters/risk-callers.js +3 -0
  17. package/dist/adapters/shell.js +23 -7
  18. package/dist/adapters/test-inventory.js +10 -2
  19. package/dist/configuration.js +17 -7
  20. package/dist/constants.js +26 -5
  21. package/dist/core/ask-references.js +193 -109
  22. package/dist/core/asks.js +78 -7
  23. package/dist/core/locate.js +8 -8
  24. package/dist/core/output.js +17 -0
  25. package/dist/core/result-report.js +302 -0
  26. package/dist/core/secret-path.js +34 -0
  27. package/dist/core/state.js +8 -1
  28. package/dist/core/units.js +1 -1
  29. package/dist/jev/client.js +34 -12
  30. package/dist/mcp/protocol.js +50 -27
  31. package/dist/mcp/tools.js +20 -7
  32. package/dist/render.js +72 -0
  33. package/dist/report-schema.js +1356 -0
  34. package/dist/result-types.js +1 -0
  35. package/dist/texts/ask-files.js +3 -1
  36. package/dist/texts/ask.js +3 -1
  37. package/dist/texts/check-diff.js +7 -4
  38. package/dist/texts/find.js +7 -2
  39. package/dist/texts/guide.js +3 -16
  40. package/dist/texts/instructions.js +72 -0
  41. package/dist/texts/locate.js +7 -2
  42. package/dist/texts/select-tests.js +3 -1
  43. package/dist/tools/ask-files.js +248 -15
  44. package/dist/tools/ask.js +523 -62
  45. package/dist/tools/check-diff.js +222 -30
  46. package/dist/tools/docs-check.js +122 -13
  47. package/dist/tools/find.js +320 -27
  48. package/dist/tools/locate.js +317 -18
  49. package/dist/tools/review-report.js +230 -0
  50. package/dist/tools/select-tests.js +273 -19
  51. package/dist/tools/spec-check.js +119 -22
  52. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +3 -3
  53. package/docs/agent-instructions.md +59 -30
  54. package/docs/design.md +13 -1
  55. package/docs/mcp.md +8 -6
  56. package/docs/tools/jev_ask.md +8 -5
  57. package/docs/tools/jev_ask_files.md +2 -1
  58. package/docs/tools/jev_check_diff.md +4 -1
  59. package/docs/tools/jev_find_files.md +2 -1
  60. package/docs/tools/jev_locate_in_file.md +5 -0
  61. package/docs/tools/jev_select_tests.md +4 -1
  62. package/package.json +1 -1
  63. package/rules/jev-ask.md +22 -1
  64. package/server.json +2 -2
  65. package/src/adapters/ask-files.ts +11 -3
  66. package/src/adapters/ask-proof.ts +69 -11
  67. package/src/adapters/command.ts +96 -33
  68. package/src/adapters/docs.ts +33 -14
  69. package/src/adapters/evidence-context.ts +169 -0
  70. package/src/adapters/files.ts +146 -16
  71. package/src/adapters/find.ts +37 -7
  72. package/src/adapters/git-base.ts +7 -1
  73. package/src/adapters/git.ts +61 -8
  74. package/src/adapters/locate-file.ts +51 -9
  75. package/src/adapters/private-storage.ts +17 -5
  76. package/src/adapters/risk-callers.ts +3 -0
  77. package/src/adapters/shell.ts +23 -7
  78. package/src/adapters/test-inventory.ts +12 -4
  79. package/src/configuration.ts +16 -2
  80. package/src/constants.ts +26 -5
  81. package/src/core/ask-references.ts +262 -146
  82. package/src/core/asks.ts +79 -7
  83. package/src/core/import-boundaries.ts +8 -3
  84. package/src/core/locate.ts +8 -5
  85. package/src/core/output.ts +34 -0
  86. package/src/core/result-report.ts +410 -0
  87. package/src/core/secret-path.ts +37 -0
  88. package/src/core/state.ts +8 -1
  89. package/src/core/units.ts +3 -2
  90. package/src/index.ts +3 -0
  91. package/src/jev/client.ts +54 -16
  92. package/src/jev/types.ts +18 -3
  93. package/src/mcp/protocol.ts +91 -41
  94. package/src/mcp/tools.ts +26 -13
  95. package/src/render.ts +109 -0
  96. package/src/report-schema.ts +1380 -0
  97. package/src/result-types.ts +234 -0
  98. package/src/result.ts +4 -1
  99. package/src/runtime.ts +6 -0
  100. package/src/texts/ask-files.ts +4 -1
  101. package/src/texts/ask.ts +8 -1
  102. package/src/texts/check-diff.ts +7 -4
  103. package/src/texts/find.ts +8 -2
  104. package/src/texts/guide.ts +8 -16
  105. package/src/texts/instructions.ts +98 -0
  106. package/src/texts/locate.ts +8 -2
  107. package/src/texts/run-end.ts +2 -2
  108. package/src/texts/select-tests.ts +4 -1
  109. package/src/tools/ask-files.ts +309 -14
  110. package/src/tools/ask.ts +700 -77
  111. package/src/tools/check-diff.ts +331 -28
  112. package/src/tools/docs-check.ts +241 -39
  113. package/src/tools/find.ts +386 -29
  114. package/src/tools/locate.ts +384 -19
  115. package/src/tools/review-report.ts +308 -0
  116. package/src/tools/select-tests.ts +479 -21
  117. package/src/tools/spec-check.ts +193 -19
@@ -0,0 +1,302 @@
1
+ import { askBand } from "./output.js";
2
+ export const known = (value) => ({
3
+ status: "known",
4
+ value,
5
+ });
6
+ export const unknown = (reason) => ({
7
+ status: "unknown",
8
+ reason,
9
+ });
10
+ export const notApplicable = (reason) => ({
11
+ status: "not_applicable",
12
+ reason,
13
+ });
14
+ export const notCollected = (reason) => ({
15
+ status: "not_collected",
16
+ reason,
17
+ });
18
+ export function uncollectedContext(input) {
19
+ return {
20
+ authority: input.authority,
21
+ requestedRoot: input.requestedRoot ?? notApplicable("no override requested"),
22
+ effectiveRoot: unknown("effective root not established"),
23
+ requestedBase: input.requestedBase ?? notApplicable("no base requested"),
24
+ resolvedBase: input.requestedBase
25
+ ? notCollected("base resolution not performed")
26
+ : notApplicable("no base requested"),
27
+ inventories: [],
28
+ command: input.command ?? {
29
+ execution: "not_requested",
30
+ cwd: notApplicable("no command"),
31
+ exitCode: notApplicable("no command"),
32
+ timedOut: notApplicable("no command"),
33
+ },
34
+ };
35
+ }
36
+ export function contextFromEvidence(evidence, options = {}) {
37
+ const context = uncollectedContext({
38
+ authority: evidence?.authorityReason
39
+ ? unknown(evidence.authorityReason)
40
+ : evidence?.authority
41
+ ? known(evidence.authority)
42
+ : unknown("canonical authority not established"),
43
+ ...(evidence?.requestedRoot !== undefined
44
+ ? { requestedRoot: known(evidence.requestedRoot) }
45
+ : {}),
46
+ ...(evidence?.requestedBase !== undefined
47
+ ? { requestedBase: known(evidence.requestedBase) }
48
+ : {}),
49
+ ...(options.command ? { command: options.command } : {}),
50
+ });
51
+ if (evidence?.effectiveRoot)
52
+ context.effectiveRoot = known({
53
+ path: evidence.effectiveRoot.path,
54
+ origin: evidence.effectiveRoot.origin,
55
+ commonDir: evidence.effectiveRoot.commonDir === undefined
56
+ ? unknown("Git common directory not collected")
57
+ : known(evidence.effectiveRoot.commonDir),
58
+ });
59
+ if (evidence?.resolvedBase !== undefined)
60
+ context.resolvedBase = known(evidence.resolvedBase);
61
+ else if (evidence?.requestedBase !== undefined)
62
+ context.resolvedBase = unknown("requested base not resolved");
63
+ context.inventories = options.inventories ?? [];
64
+ return context;
65
+ }
66
+ export function buildResultReport(input) {
67
+ const counts = { fresh: 0, cache: 0, notJudged: 0, static: 0 };
68
+ for (const item of input.items) {
69
+ if (item.treatment === "judged")
70
+ counts[item.source]++;
71
+ else if (item.treatment === "static")
72
+ counts.static++;
73
+ else
74
+ counts.notJudged++;
75
+ }
76
+ const admissible = counts.fresh + counts.cache + counts.static;
77
+ const usefulFallback = input.items.some((item) => item.treatment === "not_judged" && item.selection?.selected);
78
+ const missing = input.missingWork ||
79
+ counts.notJudged > 0 ||
80
+ input.diagnostics.some((d) => d.cause === "criteria_no_match" || d.effect === "blocking");
81
+ const report = {
82
+ schemaVersion: 1,
83
+ tool: input.tool,
84
+ execution: !admissible && !usefulFallback
85
+ ? input.refused
86
+ ? "refused"
87
+ : "not_judged"
88
+ : missing
89
+ ? "partial"
90
+ : "complete",
91
+ context: input.context,
92
+ items: input.items,
93
+ diagnostics: input.diagnostics,
94
+ actions: input.actions,
95
+ accounting: {
96
+ toolInvocations: 1,
97
+ httpAttempts: input.metrics.calls,
98
+ questionsSent: input.metrics.questions,
99
+ cacheHits: input.metrics.cacheHits,
100
+ cacheRequests: input.metrics.cacheRequests,
101
+ requestedResults: { total: input.total, ...counts },
102
+ auxiliary: input.auxiliary,
103
+ costUsd: input.metrics.costUsd === undefined
104
+ ? unknown("current request cost not reported")
105
+ : known(input.metrics.costUsd),
106
+ elapsedMs: input.metrics.elapsedMs,
107
+ },
108
+ };
109
+ const errors = validateResultReport(report);
110
+ if (errors.length)
111
+ throw new Error(`Invalid ResultReportV1: ${errors.join("; ")}`);
112
+ return report;
113
+ }
114
+ /** Semantic checks complement, rather than replace, the public JSON Schema. */
115
+ export function validateResultReport(report) {
116
+ const errors = [];
117
+ const counter = (value, label) => {
118
+ if (!Number.isSafeInteger(value) || value < 0)
119
+ errors.push(`invalid counter ${label}`);
120
+ };
121
+ const measurement = (value, label, probability = false) => {
122
+ if (value.status === "known" &&
123
+ (!Number.isFinite(value.value) ||
124
+ value.value < 0 ||
125
+ (probability && value.value > 1)))
126
+ errors.push(`invalid measure ${label}`);
127
+ };
128
+ const accounting = report.accounting;
129
+ for (const key of [
130
+ "httpAttempts",
131
+ "questionsSent",
132
+ "cacheHits",
133
+ "cacheRequests",
134
+ ])
135
+ counter(accounting[key], key);
136
+ if (!Number.isFinite(accounting.elapsedMs) || accounting.elapsedMs < 0)
137
+ errors.push("invalid elapsedMs");
138
+ measurement(accounting.costUsd, "costUsd");
139
+ for (const [kind, values] of Object.entries(accounting.auxiliary))
140
+ for (const [source, value] of Object.entries(values))
141
+ counter(value, `${kind}.${source}`);
142
+ for (const inv of report.context.inventories) {
143
+ for (const [name, value] of [
144
+ ["discovered", inv.discovered],
145
+ ["considered", inv.considered],
146
+ ])
147
+ if (value.status === "known")
148
+ counter(value.value, `${inv.id}.${name}`);
149
+ for (const criterion of inv.criteria)
150
+ if (criterion.matches.status === "known")
151
+ counter(criterion.matches.value, `${inv.id}.matches`);
152
+ }
153
+ const ids = (values, label) => {
154
+ const set = new Set(values.map((v) => v.id));
155
+ if (set.size !== values.length)
156
+ errors.push(`duplicate ${label} IDs`);
157
+ return set;
158
+ };
159
+ const items = ids(report.items, "item");
160
+ const diagnostics = ids(report.diagnostics, "diagnostic");
161
+ const actions = ids(report.actions, "action");
162
+ const inventories = ids(report.context.inventories, "inventory");
163
+ const groups = new Set(report.items.map((i) => i.groupId));
164
+ const refs = (values, set, label) => {
165
+ for (const id of values)
166
+ if (!set.has(id))
167
+ errors.push(`unknown ${label} ID ${id}`);
168
+ };
169
+ const scope = (s) => {
170
+ if (s.kind === "item")
171
+ refs(s.itemIds, items, "item");
172
+ else if (s.kind === "group")
173
+ refs(s.groupIds, groups, "group");
174
+ else if (s.kind === "inventory")
175
+ refs(s.inventoryIds, inventories, "inventory");
176
+ };
177
+ for (const item of report.items) {
178
+ refs(item.diagnosticIds, diagnostics, "diagnostic");
179
+ refs(item.actionIds, actions, "action");
180
+ if (item.treatment === "judged") {
181
+ ids(item.judgment.controls, `control in ${item.id}`);
182
+ measurement(item.judgment.measure.value, `${item.id}.measure`, true);
183
+ for (const raw of [
184
+ ...item.judgment.rawValues,
185
+ ...item.judgment.controls.flatMap((c) => c.rawValues),
186
+ ])
187
+ measurement(raw.probability, `${item.id}.${raw.label}`, true);
188
+ if (typeof item.judgment.result === "number" &&
189
+ !Number.isFinite(item.judgment.result))
190
+ errors.push(`invalid result ${item.id}`);
191
+ }
192
+ }
193
+ for (const d of report.diagnostics) {
194
+ scope(d.scope);
195
+ refs(d.actionIds, actions, "action");
196
+ }
197
+ for (const a of report.actions)
198
+ scope(a.scope);
199
+ for (const inv of report.context.inventories)
200
+ for (const criterion of inv.criteria)
201
+ refs(criterion.diagnosticIds, diagnostics, "diagnostic");
202
+ const c = report.accounting.requestedResults;
203
+ for (const key of ["fresh", "cache", "static", "notJudged"])
204
+ counter(c[key], `requestedResults.${key}`);
205
+ if (c.total.status === "known")
206
+ counter(c.total.value, "requestedResults.total");
207
+ const actual = { fresh: 0, cache: 0, static: 0, notJudged: 0 };
208
+ for (const i of report.items) {
209
+ if (i.treatment === "judged")
210
+ actual[i.source]++;
211
+ else if (i.treatment === "static")
212
+ actual.static++;
213
+ else
214
+ actual.notJudged++;
215
+ }
216
+ for (const key of ["fresh", "cache", "static", "notJudged"])
217
+ if (c[key] !== actual[key])
218
+ errors.push(`requestedResults.${key} does not match items`);
219
+ if (c.total.status === "known" && c.total.value !== report.items.length)
220
+ errors.push("known requested total does not match items");
221
+ const admissible = actual.fresh + actual.cache + actual.static;
222
+ const usefulFallback = report.items.some((item) => item.treatment === "not_judged" && item.selection?.selected);
223
+ if ((report.execution === "refused" || report.execution === "not_judged") &&
224
+ (admissible || usefulFallback))
225
+ errors.push("non-result execution has admissible items or useful fallback");
226
+ if ((report.execution === "complete" || report.execution === "partial") &&
227
+ !admissible &&
228
+ !usefulFallback)
229
+ errors.push("result execution has no admissible items or useful fallback");
230
+ if (report.execution === "complete" &&
231
+ (actual.notJudged ||
232
+ report.diagnostics.some((d) => d.effect === "blocking" || d.cause === "criteria_no_match")))
233
+ errors.push("complete execution omits requested work");
234
+ if (!admissible && !report.diagnostics.length)
235
+ errors.push("absent judgment needs an explicit cause");
236
+ return errors;
237
+ }
238
+ export function resultIsError(report) {
239
+ if (report.execution === "refused")
240
+ return true;
241
+ if (report.items.some((i) => i.treatment !== "not_judged" || i.selection?.selected))
242
+ return false;
243
+ return report.diagnostics.some((d) => d.effect === "blocking" &&
244
+ [
245
+ "provider_context_refusal",
246
+ "not_configured",
247
+ "service_unavailable",
248
+ "transport_failure",
249
+ "invalid_response",
250
+ "internal_error",
251
+ ].includes(d.cause));
252
+ }
253
+ /** Source must be established by the client, never guessed from footer totals. */
254
+ export function answerItemFromInput(common, input) {
255
+ if (input.unjudged)
256
+ return { ...common, treatment: "not_judged", source: "none" };
257
+ const source = input.source ?? input.answer?.source;
258
+ if (source !== "fresh" && source !== "cache")
259
+ throw new Error(`Answer source not established: ${common.id}`);
260
+ const value = input.value;
261
+ const answer = input.answer;
262
+ if (!value && !answer)
263
+ throw new Error(`Answer value not established: ${common.id}`);
264
+ const result = input.publicResult ??
265
+ (answer?.type === "bool"
266
+ ? answer.p >= 0.5
267
+ : (value?.head ??
268
+ (answer?.type === "choice" ? answer.choice : answer?.score)));
269
+ if (result === undefined)
270
+ throw new Error(`Answer result not established: ${common.id}`);
271
+ const p = value?.p ??
272
+ (answer?.type === "bool"
273
+ ? answer.p
274
+ : answer
275
+ ? Math.max(...Object.values(answer.probabilities))
276
+ : undefined);
277
+ return {
278
+ ...common,
279
+ treatment: "judged",
280
+ source,
281
+ judgment: {
282
+ band: input.band ??
283
+ (p === undefined
284
+ ? "unsure"
285
+ : askBand({
286
+ head: typeof result === "boolean" ? String(result) : result,
287
+ p,
288
+ }, input)),
289
+ result,
290
+ measure: {
291
+ kind: "probability",
292
+ value: p === undefined
293
+ ? unknown("leading probability not collected")
294
+ : known(p),
295
+ },
296
+ reason: input.reason ?? "",
297
+ uncalibrated: input.uncalibrated ?? false,
298
+ rawValues: input.rawValues ?? [],
299
+ controls: input.reportControls ?? [],
300
+ },
301
+ };
302
+ }
@@ -0,0 +1,34 @@
1
+ /** Base-name globs of likely secret files, matched case-insensitively. */
2
+ export const SECRET_NAME_GLOBS = [
3
+ ".env",
4
+ ".env.*",
5
+ "*.pem",
6
+ "id_rsa*",
7
+ "*.p12",
8
+ "credentials*",
9
+ "secrets*",
10
+ ];
11
+ /** Value-free templates that stay admitted despite the `.env.*` rule. */
12
+ const templates = [".env.example", ".env.sample", ".env.template"];
13
+ /**
14
+ * True when a path's base name matches SECRET_NAME_GLOBS (any depth,
15
+ * case-insensitive) and is not a value-free `.env` template. Such files are
16
+ * refused before their content is read, on every evidence admission path;
17
+ * there is no per-call override.
18
+ */
19
+ export function isSecretPath(path) {
20
+ const name = (path.split(/[\\/]/).pop() ?? "").toLowerCase();
21
+ if (templates.includes(name))
22
+ return false;
23
+ return (name === ".env" ||
24
+ name.startsWith(".env.") ||
25
+ name.endsWith(".pem") ||
26
+ name.startsWith("id_rsa") ||
27
+ name.endsWith(".p12") ||
28
+ name.startsWith("credentials") ||
29
+ name.startsWith("secrets"));
30
+ }
31
+ /** Refusal text for a secret-named path: names the path, never its content. */
32
+ export function secretRefusal(path) {
33
+ return `${path}: secret file name (secret_pattern): content not read, not sent to Jev`;
34
+ }
@@ -1,6 +1,13 @@
1
1
  import { ASK_NOTE_MAX_CHARS, STATE_MAX_CHARS } from "../constants.js";
2
2
  import { isRecord } from "../result.js";
3
- const reservedKeys = ["files", "files_before", "base_sha", "closure"];
3
+ const reservedKeys = [
4
+ "files",
5
+ "files_before",
6
+ "base_sha",
7
+ "closure",
8
+ "evidence",
9
+ "output",
10
+ ];
4
11
  export function assembleState(note, files) {
5
12
  if (note !== undefined && note.length > ASK_NOTE_MAX_CHARS)
6
13
  return {
@@ -75,7 +75,7 @@ export function buildUnits(files, parser) {
75
75
  const units = [];
76
76
  const limits = [];
77
77
  for (const file of files) {
78
- if (isTestFile(file.path))
78
+ if (isTestFile(file.path) && file.limitation !== "secret_pattern")
79
79
  continue;
80
80
  if (file.binary) {
81
81
  limits.push({ kind: "binary_ignored", file: file.path });
@@ -191,8 +191,14 @@ export function createJevClient(config, injected) {
191
191
  options.onUsage?.(next.usage);
192
192
  }
193
193
  };
194
- const missing = (ids, reason) => {
195
- const batchAnswers = Object.fromEntries(ids.map((id) => [id, { type: "unjudged", reason }]));
194
+ let stoppedCause;
195
+ const missing = (ids, reason, cause = stoppedCause) => {
196
+ const batchAnswers = Object.fromEntries(ids.map((id) => [
197
+ id,
198
+ { type: "unjudged", reason, ...(cause ? { cause } : {}) },
199
+ ]));
200
+ if (cause)
201
+ meta.failureCause ??= cause;
196
202
  for (const [id, answer] of Object.entries(batchAnswers))
197
203
  if (!witnessSet.has(id) || !answers[id])
198
204
  answers[id] = answer;
@@ -235,6 +241,7 @@ export function createJevClient(config, injected) {
235
241
  if (diagnostic)
236
242
  return;
237
243
  stopped = admission.error;
244
+ stoppedCause = options.admissionCause?.();
238
245
  missing(ids, stopped);
239
246
  return;
240
247
  }
@@ -272,10 +279,11 @@ export function createJevClient(config, injected) {
272
279
  if (diagnostic) {
273
280
  stopped =
274
281
  "Jev max_tokens_exceeded: state too large for a single question; remaining questions not judged.";
282
+ stoppedCause = "provider_context_refusal";
275
283
  return;
276
284
  }
277
285
  if (ids.length === 1) {
278
- missing(ids, "Jev max_tokens_exceeded: state too large for a single question; question not judged.");
286
+ missing(ids, "Jev max_tokens_exceeded: state too large for a single question; question not judged.", "provider_context_refusal");
279
287
  return;
280
288
  }
281
289
  if (!stateChecked && groups.length > 1) {
@@ -303,7 +311,7 @@ export function createJevClient(config, injected) {
303
311
  if (split.ok)
304
312
  await Promise.all(split.halves.map((half) => send(half)));
305
313
  else
306
- missing(ids, split.error);
314
+ missing(ids, split.error, "provider_context_refusal");
307
315
  return;
308
316
  }
309
317
  if (response.ok && isRecord(body)) {
@@ -312,7 +320,12 @@ export function createJevClient(config, injected) {
312
320
  const raw = isRecord(body.answers) ? body.answers : {};
313
321
  const batchAnswers = Object.fromEntries(ids.map((id) => [
314
322
  id,
315
- normalize(raw[id], selected[id]),
323
+ (() => {
324
+ const answer = normalize(raw[id], selected[id]);
325
+ return answer.type === "unjudged"
326
+ ? { ...answer, cause: "invalid_response" }
327
+ : { ...answer, source: "fresh" };
328
+ })(),
316
329
  ]));
317
330
  meta.batches?.push({ questionIds: ids, answers: batchAnswers });
318
331
  for (const [id, answer] of Object.entries(batchAnswers)) {
@@ -347,7 +360,7 @@ export function createJevClient(config, injected) {
347
360
  retryAfter * 1_000 > RETRY_MAX_MS) {
348
361
  const reason = `Jev HTTP 429, retry after ${retryAfter} s`;
349
362
  failure ??= reason;
350
- missing(ids, reason);
363
+ missing(ids, reason, "service_unavailable");
351
364
  return;
352
365
  }
353
366
  const reason = response.ok
@@ -360,7 +373,7 @@ export function createJevClient(config, injected) {
360
373
  failure ??= reason;
361
374
  if (response.status === 401 || response.status === 403)
362
375
  stopped = reason;
363
- missing(ids, reason);
376
+ missing(ids, reason, response.ok ? "invalid_response" : "service_unavailable");
364
377
  return;
365
378
  }
366
379
  if (Number.isFinite(retryAfter) && retryAfter >= 0)
@@ -374,7 +387,7 @@ export function createJevClient(config, injected) {
374
387
  const reason = `Jev request failed: ${String(error).replaceAll(config.apiKey, "[redacted]")}`;
375
388
  if (options.signal?.aborted || attempt === REQUEST_ATTEMPTS - 1) {
376
389
  failure ??= reason;
377
- missing(ids, reason);
390
+ missing(ids, reason, options.signal?.aborted ? "cancelled" : "transport_failure");
378
391
  return;
379
392
  }
380
393
  }
@@ -389,7 +402,7 @@ export function createJevClient(config, injected) {
389
402
  catch (error) {
390
403
  const reason = `Jev request failed: ${String(error).replaceAll(config.apiKey, "[redacted]")}`;
391
404
  failure ??= reason;
392
- missing(ids, reason);
405
+ missing(ids, reason, options.signal?.aborted ? "cancelled" : "transport_failure");
393
406
  return;
394
407
  }
395
408
  }
@@ -401,10 +414,16 @@ export function createJevClient(config, injected) {
401
414
  (witnesses.length > 0 && !witnessCache.has(witnessKey(group))))
402
415
  return true;
403
416
  for (const id of group) {
404
- answers[id] = structuredClone(cache.get(keys.get(id)));
417
+ answers[id] = {
418
+ ...structuredClone(cache.get(keys.get(id))),
419
+ source: "cache",
420
+ };
405
421
  meta.cacheHits = (meta.cacheHits ?? 0) + 1;
406
422
  }
407
- const observedWitnesses = structuredClone(witnessCache.get(witnessKey(group)) ?? {});
423
+ const observedWitnesses = Object.fromEntries(Object.entries(structuredClone(witnessCache.get(witnessKey(group)) ?? {})).map(([id, answer]) => [
424
+ id,
425
+ { ...answer, source: "cache" },
426
+ ]));
408
427
  for (const [id, answer] of Object.entries(observedWitnesses)) {
409
428
  if (!answers[id]) {
410
429
  answers[id] = answer;
@@ -418,7 +437,10 @@ export function createJevClient(config, injected) {
418
437
  id,
419
438
  witnessSet.has(id)
420
439
  ? observedWitnesses[id]
421
- : structuredClone(cache.get(keys.get(id))),
440
+ : {
441
+ ...structuredClone(cache.get(keys.get(id))),
442
+ source: "cache",
443
+ },
422
444
  ])),
423
445
  });
424
446
  return false;
@@ -21,6 +21,9 @@ export const METHOD_NOT_FOUND = -32601;
21
21
  export const INVALID_PARAMS = -32602;
22
22
  export const INTERNAL_ERROR = -32603;
23
23
  export const UNSUPPORTED_PROTOCOL_VERSION = -32022;
24
+ /** Input-schema failure, distinct from semantic tool refusals and server faults. */
25
+ export class McpInvalidParams extends Error {
26
+ }
24
27
  function isRecord(value) {
25
28
  return typeof value === "object" && value !== null && !Array.isArray(value);
26
29
  }
@@ -32,6 +35,7 @@ export class McpServer {
32
35
  tools;
33
36
  info;
34
37
  inFlight = new Map();
38
+ negotiatedVersion = LATEST_INITIALIZE_VERSION;
35
39
  constructor(info, tools) {
36
40
  this.info = info;
37
41
  this.tools = new Map(tools.map((tool) => [tool.name, tool]));
@@ -67,10 +71,17 @@ export class McpServer {
67
71
  supported: [...SUPPORTED_VERSIONS],
68
72
  requested,
69
73
  });
74
+ const version = typeof requested === "string"
75
+ ? requested
76
+ : method === "server/discover"
77
+ ? "2026-07-28"
78
+ : this.negotiatedVersion;
70
79
  try {
71
80
  switch (method) {
72
- case "initialize":
73
- return this.result(id, this.initialize(params));
81
+ case "initialize": {
82
+ const initialized = this.initialize(params);
83
+ return this.result(id, initialized, this.negotiatedVersion);
84
+ }
74
85
  case "server/discover":
75
86
  return this.result(id, {
76
87
  supportedVersions: [...SUPPORTED_VERSIONS],
@@ -86,20 +97,23 @@ export class McpServer {
86
97
  : {}),
87
98
  ttlMs: 0,
88
99
  cacheScope: "private",
89
- });
100
+ }, version);
90
101
  case "ping":
91
- return this.result(id, {});
102
+ return this.result(id, {}, version);
92
103
  case "tools/list":
93
104
  return this.result(id, {
94
- tools: [...this.tools.values()].map(({ call: _call, ...tool }) => tool),
95
- // CacheableResult requires these from 2026-07-28; 0/private is
96
- // conservative (immediately stale, same authorization context)
97
- // and ignored by earlier clients via the open result shape.
98
- ttlMs: 0,
99
- cacheScope: "private",
100
- });
105
+ tools: [...this.tools.values()].map(({ call: _call, outputSchema, ...tool }) => ({
106
+ ...tool,
107
+ ...(supportsStructuredResults(version) && outputSchema
108
+ ? { outputSchema }
109
+ : {}),
110
+ })),
111
+ ...(version === "2026-07-28"
112
+ ? { ttlMs: 0, cacheScope: "private" }
113
+ : {}),
114
+ }, version);
101
115
  case "tools/call":
102
- return await this.callTool(id, params);
116
+ return await this.callTool(id, params, version);
103
117
  default:
104
118
  return this.error(id, METHOD_NOT_FOUND, `Method not found: ${method}`);
105
119
  }
@@ -116,6 +130,7 @@ export class McpServer {
116
130
  SUPPORTED_VERSIONS.includes(requested)
117
131
  ? requested
118
132
  : LATEST_INITIALIZE_VERSION;
133
+ this.negotiatedVersion = protocolVersion;
119
134
  return {
120
135
  protocolVersion,
121
136
  capabilities: { tools: { listChanged: false } },
@@ -132,7 +147,7 @@ export class McpServer {
132
147
  }
133
148
  // notifications/initialized and unknown notifications need no action.
134
149
  }
135
- async callTool(id, params) {
150
+ async callTool(id, params, version) {
136
151
  const tool = typeof params.name === "string" ? this.tools.get(params.name) : undefined;
137
152
  if (!tool)
138
153
  return this.error(id, INVALID_PARAMS, `Unknown tool: ${String(params.name)}`);
@@ -149,32 +164,35 @@ export class McpServer {
149
164
  const result = await tool.call(params.arguments ?? {}, controller.signal);
150
165
  if (cancelled())
151
166
  return undefined;
152
- return this.result(id, { ...result });
167
+ const { structuredContent, ...content } = result;
168
+ return this.result(id, {
169
+ ...content,
170
+ ...(supportsStructuredResults(version) && structuredContent
171
+ ? { structuredContent }
172
+ : {}),
173
+ }, version);
153
174
  }
154
175
  catch (error) {
155
176
  if (cancelled())
156
177
  return undefined;
157
- // Tool execution failures are results the model can read, not protocol errors.
158
- return this.result(id, {
159
- content: [
160
- {
161
- type: "text",
162
- text: `${tool.name} failed: ${error instanceof Error ? error.message : String(error)}`,
163
- },
164
- ],
165
- isError: true,
166
- });
178
+ if (error instanceof McpInvalidParams)
179
+ return this.error(id, INVALID_PARAMS, error.message);
180
+ // Expected tool refusals already carry a report. An unexpected server
181
+ // fault has no trustworthy report/accounting; do not fabricate one.
182
+ return this.error(id, INTERNAL_ERROR, `${tool.name} failed: ${error instanceof Error ? error.message : String(error)}`);
167
183
  }
168
184
  finally {
169
185
  this.inFlight.delete(id);
170
186
  }
171
187
  }
172
- result(id, result) {
173
- // resultType is required from 2026-07-28 and ignored by earlier clients.
188
+ result(id, result, version) {
174
189
  return {
175
190
  jsonrpc: "2.0",
176
191
  id,
177
- result: { resultType: "complete", ...result },
192
+ result: {
193
+ ...(version === "2026-07-28" ? { resultType: "complete" } : {}),
194
+ ...result,
195
+ },
178
196
  };
179
197
  }
180
198
  error(id, code, message, data) {
@@ -185,3 +203,8 @@ export class McpServer {
185
203
  };
186
204
  }
187
205
  }
206
+ function supportsStructuredResults(version) {
207
+ return (version === "2025-06-18" ||
208
+ version === "2025-11-25" ||
209
+ version === "2026-07-28");
210
+ }