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.
- package/CHANGELOG.md +106 -1
- package/CONTRIBUTING.md +43 -0
- package/README.md +58 -17
- package/SECURITY.md +43 -0
- package/dist/adapters/analysis-context.js +75 -0
- package/dist/adapters/ask-files.js +198 -0
- package/dist/adapters/ask-proof.js +200 -0
- package/dist/adapters/ask-syntax.js +385 -0
- package/dist/adapters/canonical-path.js +17 -0
- package/dist/adapters/command.js +234 -0
- package/dist/adapters/docs.js +192 -0
- package/dist/adapters/evidence-context.js +119 -0
- package/dist/adapters/exec.js +207 -0
- package/dist/adapters/files.js +418 -0
- package/dist/adapters/find.js +150 -0
- package/dist/adapters/git-base.js +32 -0
- package/dist/adapters/git-inventory.js +71 -0
- package/dist/adapters/git.js +483 -0
- package/dist/adapters/locate-file.js +197 -0
- package/dist/adapters/output-lines.js +46 -0
- package/dist/adapters/private-storage.js +106 -0
- package/dist/adapters/risk-callers.js +429 -0
- package/dist/adapters/runner-version.js +78 -0
- package/dist/adapters/shell.js +92 -0
- package/dist/adapters/syntax.js +187 -0
- package/dist/adapters/test-inventory.js +139 -0
- package/dist/adapters/usage.js +20 -0
- package/dist/adapters/utf8.js +47 -0
- package/dist/configuration.js +267 -0
- package/dist/constants.js +140 -0
- package/dist/core/ask-closure.js +282 -0
- package/dist/core/ask-proof.js +1 -0
- package/dist/core/ask-references.js +278 -0
- package/dist/core/asks.js +507 -0
- package/dist/core/batches.js +65 -0
- package/dist/core/command-output.js +224 -0
- package/dist/core/diff.js +178 -0
- package/dist/core/docs.js +302 -0
- package/dist/core/find.js +108 -0
- package/dist/core/git.js +1 -0
- package/dist/core/imports.js +550 -0
- package/dist/core/integrity.js +45 -0
- package/dist/core/lexical.js +132 -0
- package/dist/core/locate.js +169 -0
- package/dist/core/output.js +137 -0
- package/dist/core/pointer.js +29 -0
- package/dist/core/result-report.js +302 -0
- package/dist/core/risk-callers.js +851 -0
- package/dist/core/runner-version.js +45 -0
- package/dist/core/secret-path.js +34 -0
- package/dist/core/sections.js +230 -0
- package/dist/core/state.js +51 -0
- package/dist/core/syntax.js +1 -0
- package/dist/core/test-commands.js +334 -0
- package/dist/core/test-coverage.js +74 -0
- package/dist/core/test-discovery.js +1382 -0
- package/dist/core/test-evidence.js +527 -0
- package/dist/core/test-state.js +81 -0
- package/dist/core/truncate.js +12 -0
- package/dist/core/units.js +349 -0
- package/dist/describe.js +23 -0
- package/dist/guide.js +33 -0
- package/dist/host.js +24 -0
- package/dist/jev/client.js +456 -0
- package/dist/jev/pool.js +54 -0
- package/dist/jev/types.js +1 -0
- package/dist/mcp/main.js +124 -0
- package/dist/mcp/protocol.js +210 -0
- package/dist/mcp/tools.js +129 -0
- package/dist/presets/docs.js +62 -0
- package/dist/presets/risk.js +179 -0
- package/dist/presets/spec.js +81 -0
- package/dist/presets/witnesses.js +249 -0
- package/dist/render.js +114 -0
- package/dist/report-schema.js +1356 -0
- package/dist/result-types.js +1 -0
- package/dist/result.js +3 -0
- package/dist/runtime.js +1 -0
- package/dist/session.js +147 -0
- package/dist/texts/ask-files.js +3 -0
- package/dist/texts/ask.js +4 -0
- package/dist/texts/check-diff.js +20 -0
- package/dist/texts/configuration.js +1 -0
- package/dist/texts/find.js +19 -0
- package/dist/texts/guide.js +3 -0
- package/dist/texts/instructions.js +72 -0
- package/dist/texts/locate.js +15 -0
- package/dist/texts/select-tests.js +4 -0
- package/dist/tools/ask-files.js +450 -0
- package/dist/tools/ask-schema.js +70 -0
- package/dist/tools/ask.js +1147 -0
- package/dist/tools/check-diff.js +594 -0
- package/dist/tools/docs-check.js +408 -0
- package/dist/tools/find.js +682 -0
- package/dist/tools/locate.js +602 -0
- package/dist/tools/review-report.js +230 -0
- package/dist/tools/select-tests.js +821 -0
- package/dist/tools/spec-check.js +263 -0
- package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
- package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
- package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
- package/docs/adr/0004-compiled-typed-intents.md +19 -0
- package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
- package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
- package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
- package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
- package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
- package/docs/adr/0010-mcp-server-thin-host.md +23 -0
- package/docs/agent-instructions.md +120 -0
- package/docs/design.md +16 -4
- package/docs/mcp.md +233 -0
- package/docs/tools/jev_ask.md +8 -5
- package/docs/tools/jev_ask_files.md +2 -1
- package/docs/tools/jev_check_diff.md +4 -1
- package/docs/tools/jev_find_files.md +2 -1
- package/docs/tools/jev_locate_in_file.md +5 -0
- package/docs/tools/jev_select_tests.md +4 -1
- package/package.json +19 -4
- package/rules/jev-ask.md +22 -1
- package/server.json +57 -0
- package/src/adapters/ask-files.ts +11 -3
- package/src/adapters/ask-proof.ts +69 -11
- package/src/adapters/canonical-path.ts +18 -0
- package/src/adapters/command.ts +102 -36
- package/src/adapters/docs.ts +33 -14
- package/src/adapters/evidence-context.ts +169 -0
- package/src/adapters/exec.ts +226 -0
- package/src/adapters/files.ts +146 -16
- package/src/adapters/find.ts +37 -7
- package/src/adapters/git-base.ts +7 -1
- package/src/adapters/git.ts +61 -8
- package/src/adapters/locate-file.ts +51 -9
- package/src/adapters/private-storage.ts +155 -0
- package/src/adapters/risk-callers.ts +7 -2
- package/src/adapters/shell.ts +113 -0
- package/src/adapters/test-inventory.ts +12 -4
- package/src/configuration.ts +55 -14
- package/src/constants.ts +37 -5
- package/src/core/ask-references.ts +262 -146
- package/src/core/asks.ts +79 -7
- package/src/core/command-output.ts +17 -1
- package/src/core/import-boundaries.ts +8 -3
- package/src/core/locate.ts +8 -5
- package/src/core/output.ts +34 -0
- package/src/core/result-report.ts +410 -0
- package/src/core/secret-path.ts +37 -0
- package/src/core/state.ts +8 -1
- package/src/core/units.ts +3 -2
- package/src/host.ts +11 -0
- package/src/index.ts +3 -0
- package/src/jev/client.ts +66 -16
- package/src/jev/types.ts +24 -3
- package/src/mcp/main.ts +135 -0
- package/src/mcp/protocol.ts +332 -0
- package/src/mcp/tools.ts +179 -0
- package/src/render.ts +109 -0
- package/src/report-schema.ts +1380 -0
- package/src/result-types.ts +234 -0
- package/src/result.ts +4 -1
- package/src/runtime.ts +6 -0
- package/src/session.ts +59 -0
- package/src/setup.ts +13 -5
- package/src/texts/ask-files.ts +4 -1
- package/src/texts/ask.ts +8 -1
- package/src/texts/check-diff.ts +7 -4
- package/src/texts/find.ts +8 -2
- package/src/texts/guide.ts +8 -16
- package/src/texts/instructions.ts +98 -0
- package/src/texts/locate.ts +8 -2
- package/src/texts/run-end.ts +2 -2
- package/src/texts/select-tests.ts +4 -1
- package/src/tools/ask-files.ts +311 -18
- package/src/tools/ask.ts +722 -95
- package/src/tools/check-diff.ts +337 -31
- package/src/tools/docs-check.ts +241 -38
- package/src/tools/find.ts +389 -29
- package/src/tools/locate.ts +387 -25
- package/src/tools/review-report.ts +308 -0
- package/src/tools/select-tests.ts +484 -23
- 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
|
-
|
|
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) => [
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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] =
|
|
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 =
|
|
470
|
-
|
|
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
|
-
:
|
|
487
|
-
|
|
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
|
-
| {
|
|
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 {
|
package/src/mcp/main.ts
ADDED
|
@@ -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
|
+
}
|