jev-agent-tools 0.1.3 → 0.2.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 (135) hide show
  1. package/CHANGELOG.md +88 -3
  2. package/CONTRIBUTING.md +40 -0
  3. package/README.md +64 -9
  4. package/SECURITY.md +27 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +189 -0
  7. package/dist/adapters/ask-proof.js +144 -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 +181 -0
  11. package/dist/adapters/docs.js +172 -0
  12. package/dist/adapters/exec.js +207 -0
  13. package/dist/adapters/files.js +293 -0
  14. package/dist/adapters/find.js +122 -0
  15. package/dist/adapters/git-base.js +26 -0
  16. package/dist/adapters/git-inventory.js +71 -0
  17. package/dist/adapters/git.js +439 -0
  18. package/dist/adapters/locate-file.js +159 -0
  19. package/dist/adapters/output-lines.js +46 -0
  20. package/dist/adapters/private-storage.js +98 -0
  21. package/dist/adapters/risk-callers.js +426 -0
  22. package/dist/adapters/runner-version.js +78 -0
  23. package/dist/adapters/shell.js +76 -0
  24. package/dist/adapters/syntax.js +187 -0
  25. package/dist/adapters/test-inventory.js +131 -0
  26. package/dist/adapters/usage.js +20 -0
  27. package/dist/adapters/utf8.js +47 -0
  28. package/dist/configuration.js +257 -0
  29. package/dist/constants.js +119 -0
  30. package/dist/core/ask-closure.js +282 -0
  31. package/dist/core/ask-proof.js +1 -0
  32. package/dist/core/ask-references.js +194 -0
  33. package/dist/core/asks.js +436 -0
  34. package/dist/core/batches.js +65 -0
  35. package/dist/core/command-output.js +224 -0
  36. package/dist/core/diff.js +178 -0
  37. package/dist/core/docs.js +302 -0
  38. package/dist/core/find.js +108 -0
  39. package/dist/core/git.js +1 -0
  40. package/dist/core/imports.js +550 -0
  41. package/dist/core/integrity.js +45 -0
  42. package/dist/core/lexical.js +132 -0
  43. package/dist/core/locate.js +169 -0
  44. package/dist/core/output.js +120 -0
  45. package/dist/core/pointer.js +29 -0
  46. package/dist/core/risk-callers.js +851 -0
  47. package/dist/core/runner-version.js +45 -0
  48. package/dist/core/sections.js +230 -0
  49. package/dist/core/state.js +44 -0
  50. package/dist/core/syntax.js +1 -0
  51. package/dist/core/test-commands.js +334 -0
  52. package/dist/core/test-coverage.js +74 -0
  53. package/dist/core/test-discovery.js +1382 -0
  54. package/dist/core/test-evidence.js +527 -0
  55. package/dist/core/test-state.js +81 -0
  56. package/dist/core/truncate.js +12 -0
  57. package/dist/core/units.js +349 -0
  58. package/dist/describe.js +23 -0
  59. package/dist/guide.js +33 -0
  60. package/dist/host.js +24 -0
  61. package/dist/jev/client.js +434 -0
  62. package/dist/jev/pool.js +54 -0
  63. package/dist/jev/types.js +1 -0
  64. package/dist/mcp/main.js +124 -0
  65. package/dist/mcp/protocol.js +187 -0
  66. package/dist/mcp/tools.js +116 -0
  67. package/dist/presets/docs.js +62 -0
  68. package/dist/presets/risk.js +179 -0
  69. package/dist/presets/spec.js +81 -0
  70. package/dist/presets/witnesses.js +249 -0
  71. package/dist/render.js +42 -0
  72. package/dist/result.js +3 -0
  73. package/dist/runtime.js +1 -0
  74. package/dist/session.js +147 -0
  75. package/dist/texts/ask-files.js +1 -0
  76. package/dist/texts/ask.js +2 -0
  77. package/dist/texts/check-diff.js +17 -0
  78. package/dist/texts/configuration.js +1 -0
  79. package/dist/texts/find.js +14 -0
  80. package/dist/texts/guide.js +16 -0
  81. package/dist/texts/locate.js +10 -0
  82. package/dist/texts/select-tests.js +2 -0
  83. package/dist/tools/ask-files.js +217 -0
  84. package/dist/tools/ask-schema.js +70 -0
  85. package/dist/tools/ask.js +686 -0
  86. package/dist/tools/check-diff.js +402 -0
  87. package/dist/tools/docs-check.js +299 -0
  88. package/dist/tools/find.js +389 -0
  89. package/dist/tools/locate.js +303 -0
  90. package/dist/tools/select-tests.js +567 -0
  91. package/dist/tools/spec-check.js +166 -0
  92. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  93. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  94. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  95. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  96. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  97. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  98. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  99. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  100. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  101. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  102. package/docs/agent-instructions.md +91 -0
  103. package/docs/design.md +3 -3
  104. package/docs/mcp.md +231 -0
  105. package/package.json +19 -4
  106. package/server.json +57 -0
  107. package/src/adapters/canonical-path.ts +18 -0
  108. package/src/adapters/command.ts +7 -4
  109. package/src/adapters/exec.ts +226 -0
  110. package/src/adapters/private-storage.ts +143 -0
  111. package/src/adapters/risk-callers.ts +4 -2
  112. package/src/adapters/shell.ts +97 -0
  113. package/src/configuration.ts +294 -0
  114. package/src/constants.ts +11 -0
  115. package/src/core/command-output.ts +17 -1
  116. package/src/host-tui.d.ts +14 -0
  117. package/src/host.ts +11 -0
  118. package/src/index.ts +13 -5
  119. package/src/jev/client.ts +12 -0
  120. package/src/jev/types.ts +6 -0
  121. package/src/mcp/main.ts +135 -0
  122. package/src/mcp/protocol.ts +282 -0
  123. package/src/mcp/tools.ts +166 -0
  124. package/src/secret-input.ts +222 -0
  125. package/src/session.ts +59 -0
  126. package/src/setup.ts +170 -0
  127. package/src/texts/configuration.ts +1 -1
  128. package/src/tools/ask-files.ts +8 -13
  129. package/src/tools/ask.ts +29 -28
  130. package/src/tools/check-diff.ts +11 -11
  131. package/src/tools/docs-check.ts +1 -0
  132. package/src/tools/find.ts +8 -8
  133. package/src/tools/locate.ts +8 -9
  134. package/src/tools/select-tests.ts +10 -10
  135. package/src/tools/spec-check.ts +1 -0
package/src/jev/client.ts CHANGED
@@ -259,6 +259,7 @@ export function createJevClient(
259
259
  });
260
260
  for (let attempt = 0; attempt < REQUEST_ATTEMPTS; attempt++) {
261
261
  let release: (() => void) | undefined;
262
+ let settle: (() => void) | undefined;
262
263
  const timeoutCancellation = new AbortController();
263
264
  let retryMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** attempt);
264
265
  try {
@@ -267,6 +268,13 @@ export function createJevClient(
267
268
  missing(ids, stopped);
268
269
  return;
269
270
  }
271
+ // The reservation is held from here on; `finally` releases it on
272
+ // every exit path (stopped, refused, failed, aborted, answered).
273
+ settle = await options.awaitAdmission?.(options.signal);
274
+ if (stopped) {
275
+ missing(ids, stopped);
276
+ return;
277
+ }
270
278
  const admission = options.beforeRequest?.(ids.length);
271
279
  if (admission && !admission.ok) {
272
280
  // A diagnostic denied admission supplies no judgment batch and
@@ -302,6 +310,9 @@ export function createJevClient(
302
310
  body = undefined;
303
311
  }
304
312
  addMetadata(body);
313
+ // Release the USD gate before any subdivision re-enters send().
314
+ settle?.();
315
+ settle = undefined;
305
316
  release();
306
317
  release = undefined;
307
318
  if (
@@ -438,6 +449,7 @@ export function createJevClient(
438
449
  return;
439
450
  }
440
451
  } finally {
452
+ settle?.();
441
453
  release?.();
442
454
  timeoutCancellation.abort();
443
455
  }
package/src/jev/types.ts CHANGED
@@ -48,6 +48,12 @@ export interface JudgmentOptions {
48
48
  witnesses?: readonly string[];
49
49
  cache?: boolean;
50
50
  beforeRequest?: (questionCount: number) => Result<object>;
51
+ /**
52
+ * Awaited before each admission check. Resolves to a release function when
53
+ * it reserved a slot (a session under a USD limit), which the client calls
54
+ * exactly once on every exit path; rejects if `signal` aborts while waiting.
55
+ */
56
+ awaitAdmission?: (signal?: AbortSignal) => Promise<(() => void) | undefined>;
51
57
  onUsage?: (usage: { inputTokens: number; costUsd: number }) => void;
52
58
  }
53
59
  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,282 @@
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
+ export interface McpContent {
28
+ type: "text";
29
+ text: string;
30
+ }
31
+ export interface McpCallResult {
32
+ content: McpContent[];
33
+ isError?: boolean;
34
+ }
35
+ export interface McpTool {
36
+ name: string;
37
+ title?: string;
38
+ description: string;
39
+ inputSchema: { type: "object"; [key: string]: unknown };
40
+ annotations?: {
41
+ title?: string;
42
+ readOnlyHint?: boolean;
43
+ destructiveHint?: boolean;
44
+ idempotentHint?: boolean;
45
+ openWorldHint?: boolean;
46
+ };
47
+ call(
48
+ args: Record<string, unknown>,
49
+ signal: AbortSignal,
50
+ ): Promise<McpCallResult>;
51
+ }
52
+ export interface McpServerInfo {
53
+ name: string;
54
+ version: string;
55
+ instructions?: string;
56
+ }
57
+
58
+ type Id = string | number;
59
+ export type JsonRpcResponse =
60
+ | { jsonrpc: "2.0"; id: Id | null; result: Record<string, unknown> }
61
+ | {
62
+ jsonrpc: "2.0";
63
+ id: Id | null;
64
+ error: { code: number; message: string; data?: unknown };
65
+ };
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
69
+ }
70
+ function isId(value: unknown): value is Id {
71
+ return (
72
+ typeof value === "string" ||
73
+ (typeof value === "number" && Number.isFinite(value))
74
+ );
75
+ }
76
+
77
+ export class McpServer {
78
+ private readonly tools: Map<string, McpTool>;
79
+ private readonly info: McpServerInfo;
80
+ private readonly inFlight = new Map<Id, AbortController>();
81
+
82
+ constructor(info: McpServerInfo, tools: readonly McpTool[]) {
83
+ this.info = info;
84
+ this.tools = new Map(tools.map((tool) => [tool.name, tool]));
85
+ }
86
+
87
+ /** Abort every running tool call, e.g. when stdin closes. */
88
+ abortAll(): void {
89
+ for (const controller of this.inFlight.values()) controller.abort();
90
+ this.inFlight.clear();
91
+ }
92
+
93
+ /** Handle one decoded JSON-RPC message; notifications return undefined. */
94
+ async handle(message: unknown): Promise<JsonRpcResponse | undefined> {
95
+ if (!isRecord(message) || message.jsonrpc !== "2.0")
96
+ return this.error(
97
+ isRecord(message) && isId(message.id) ? message.id : null,
98
+ INVALID_REQUEST,
99
+ "Invalid JSON-RPC 2.0 message.",
100
+ );
101
+ const { method, id } = message;
102
+ const params = isRecord(message.params) ? message.params : {};
103
+ // Responses to server-initiated requests: this server sends none.
104
+ if (method === undefined && ("result" in message || "error" in message))
105
+ return undefined;
106
+ if (typeof method !== "string")
107
+ return this.error(
108
+ isId(id) ? id : null,
109
+ INVALID_REQUEST,
110
+ "Missing method.",
111
+ );
112
+ if (id === undefined) {
113
+ this.notify(method, params);
114
+ return undefined;
115
+ }
116
+ if (!isId(id))
117
+ return this.error(
118
+ null,
119
+ INVALID_REQUEST,
120
+ "Request id must be a string or number.",
121
+ );
122
+ const meta = isRecord(params._meta) ? params._meta : {};
123
+ const requested = meta[VERSION_META];
124
+ if (
125
+ typeof requested === "string" &&
126
+ !(SUPPORTED_VERSIONS as readonly string[]).includes(requested)
127
+ )
128
+ return this.error(
129
+ id,
130
+ UNSUPPORTED_PROTOCOL_VERSION,
131
+ "Unsupported protocol version.",
132
+ {
133
+ supported: [...SUPPORTED_VERSIONS],
134
+ requested,
135
+ },
136
+ );
137
+ try {
138
+ switch (method) {
139
+ case "initialize":
140
+ return this.result(id, this.initialize(params));
141
+ case "server/discover":
142
+ return this.result(id, {
143
+ supportedVersions: [...SUPPORTED_VERSIONS],
144
+ capabilities: { tools: { listChanged: false } },
145
+ _meta: {
146
+ "io.modelcontextprotocol/serverInfo": {
147
+ name: this.info.name,
148
+ version: this.info.version,
149
+ },
150
+ },
151
+ ...(this.info.instructions
152
+ ? { instructions: this.info.instructions }
153
+ : {}),
154
+ ttlMs: 0,
155
+ cacheScope: "private",
156
+ });
157
+ case "ping":
158
+ return this.result(id, {});
159
+ case "tools/list":
160
+ return this.result(id, {
161
+ tools: [...this.tools.values()].map(
162
+ ({ call: _call, ...tool }) => tool,
163
+ ),
164
+ // CacheableResult requires these from 2026-07-28; 0/private is
165
+ // conservative (immediately stale, same authorization context)
166
+ // and ignored by earlier clients via the open result shape.
167
+ ttlMs: 0,
168
+ cacheScope: "private",
169
+ });
170
+ case "tools/call":
171
+ return await this.callTool(id, params);
172
+ default:
173
+ return this.error(
174
+ id,
175
+ METHOD_NOT_FOUND,
176
+ `Method not found: ${method}`,
177
+ );
178
+ }
179
+ } catch (error) {
180
+ return this.error(
181
+ id,
182
+ INTERNAL_ERROR,
183
+ error instanceof Error ? error.message : String(error),
184
+ );
185
+ }
186
+ }
187
+
188
+ private initialize(params: Record<string, unknown>): Record<string, unknown> {
189
+ const requested = params.protocolVersion;
190
+ // Echo a supported initialize-era version, else offer our latest one.
191
+ const protocolVersion =
192
+ typeof requested === "string" &&
193
+ requested !== "2026-07-28" &&
194
+ (SUPPORTED_VERSIONS as readonly string[]).includes(requested)
195
+ ? requested
196
+ : LATEST_INITIALIZE_VERSION;
197
+ return {
198
+ protocolVersion,
199
+ capabilities: { tools: { listChanged: false } },
200
+ serverInfo: { name: this.info.name, version: this.info.version },
201
+ ...(this.info.instructions
202
+ ? { instructions: this.info.instructions }
203
+ : {}),
204
+ };
205
+ }
206
+
207
+ private notify(method: string, params: Record<string, unknown>): void {
208
+ if (method === "notifications/cancelled" && isId(params.requestId)) {
209
+ this.inFlight.get(params.requestId)?.abort();
210
+ this.inFlight.delete(params.requestId);
211
+ }
212
+ // notifications/initialized and unknown notifications need no action.
213
+ }
214
+
215
+ private async callTool(
216
+ id: Id,
217
+ params: Record<string, unknown>,
218
+ ): Promise<JsonRpcResponse | undefined> {
219
+ const tool =
220
+ typeof params.name === "string" ? this.tools.get(params.name) : undefined;
221
+ if (!tool)
222
+ return this.error(
223
+ id,
224
+ INVALID_PARAMS,
225
+ `Unknown tool: ${String(params.name)}`,
226
+ );
227
+ if (params.arguments !== undefined && !isRecord(params.arguments))
228
+ return this.error(
229
+ id,
230
+ INVALID_PARAMS,
231
+ "Tool arguments must be an object.",
232
+ );
233
+ const controller = new AbortController();
234
+ this.inFlight.set(id, controller);
235
+ // A cancelled request gets no response on any path: 2025-11-25 says
236
+ // receivers SHOULD NOT respond, and the 2026-07-28 stdio transport says
237
+ // servers MUST NOT send further messages for it. The signal stays aborted
238
+ // after the notification removes the entry, so the check is race-free.
239
+ const cancelled = () => controller.signal.aborted;
240
+ try {
241
+ const result = await tool.call(params.arguments ?? {}, controller.signal);
242
+ if (cancelled()) return undefined;
243
+ return this.result(id, { ...result });
244
+ } catch (error) {
245
+ if (cancelled()) return undefined;
246
+ // Tool execution failures are results the model can read, not protocol errors.
247
+ return this.result(id, {
248
+ content: [
249
+ {
250
+ type: "text",
251
+ text: `${tool.name} failed: ${error instanceof Error ? error.message : String(error)}`,
252
+ },
253
+ ],
254
+ isError: true,
255
+ });
256
+ } finally {
257
+ this.inFlight.delete(id);
258
+ }
259
+ }
260
+
261
+ private result(id: Id, result: Record<string, unknown>): JsonRpcResponse {
262
+ // resultType is required from 2026-07-28 and ignored by earlier clients.
263
+ return {
264
+ jsonrpc: "2.0",
265
+ id,
266
+ result: { resultType: "complete", ...result },
267
+ };
268
+ }
269
+
270
+ error(
271
+ id: Id | null,
272
+ code: number,
273
+ message: string,
274
+ data?: unknown,
275
+ ): JsonRpcResponse {
276
+ return {
277
+ jsonrpc: "2.0",
278
+ id,
279
+ error: { code, message, ...(data === undefined ? {} : { data }) },
280
+ };
281
+ }
282
+ }
@@ -0,0 +1,166 @@
1
+ import type { TSchema } from "@sinclair/typebox";
2
+ import { Value } from "@sinclair/typebox/value";
3
+ import { spawnExec } from "../adapters/exec.ts";
4
+ import { ConfigController } from "../configuration.ts";
5
+ import { MCP_VALIDATION_MAX_ERRORS } from "../constants.ts";
6
+ import type { GitExec } from "../core/git.ts";
7
+ import { Guide } from "../guide.ts";
8
+ import { mcpHost } from "../host.ts";
9
+ import type { JevClient } from "../jev/types.ts";
10
+ import type { ToolDependencies } from "../runtime.ts";
11
+ import { readSessionLimits, Session } from "../session.ts";
12
+ import { createAskTool } from "../tools/ask.ts";
13
+ import { createAskFilesTool } from "../tools/ask-files.ts";
14
+ import { createCheckDiffTool } from "../tools/check-diff.ts";
15
+ import { createFindFilesTool } from "../tools/find.ts";
16
+ import { createLocateTool } from "../tools/locate.ts";
17
+ import { createSelectTestsTool } from "../tools/select-tests.ts";
18
+ import type { McpTool } from "./protocol.ts";
19
+
20
+ /** The shape every tool factory already returns for pi/omp. */
21
+ interface HarnessTool {
22
+ name: string;
23
+ label: string;
24
+ description: string;
25
+ parameters: TSchema;
26
+ promptGuidelines?: string[];
27
+ execute(
28
+ id: string,
29
+ args: never,
30
+ signal: AbortSignal | undefined,
31
+ update: unknown,
32
+ ctx: { cwd: string },
33
+ ): Promise<{ content: { type: "text"; text: string }[] }>;
34
+ }
35
+
36
+ export interface McpToolOptions {
37
+ /** Repository directory the tools operate in. */
38
+ root: string;
39
+ env?: NodeJS.ProcessEnv;
40
+ /** Test seams; production uses the configured HTTP client and spawn. */
41
+ client?: JevClient;
42
+ exec?: GitExec;
43
+ /** Saved-configuration directory; defaults to the pi/omp setup location. */
44
+ configDirectory?: string;
45
+ }
46
+
47
+ function validationError(schema: TSchema, value: unknown): string | undefined {
48
+ if (Value.Check(schema, value)) return undefined;
49
+ const problems: string[] = [];
50
+ for (const error of Value.Errors(schema, value)) {
51
+ problems.push(`${error.path || "/"}: ${error.message}`);
52
+ if (problems.length === MCP_VALIDATION_MAX_ERRORS) break;
53
+ }
54
+ return `Invalid arguments. ${problems.join("; ")}`;
55
+ }
56
+
57
+ /**
58
+ * Effective Jev client for MCP, with the same precedence as pi/omp minus the
59
+ * interactive layers: environment variables, then the configuration saved by
60
+ * `/jev-setup` in pi or omp. Storage problems never stop the server; the tools
61
+ * then explain the missing configuration and `warning` says why.
62
+ */
63
+ export async function loadMcpClient(
64
+ env: NodeJS.ProcessEnv,
65
+ configDirectory?: string,
66
+ ): Promise<{ client?: JevClient; warning?: string }> {
67
+ try {
68
+ const controller = new ConfigController({
69
+ env,
70
+ ...(configDirectory ? { directory: configDirectory } : {}),
71
+ });
72
+ try {
73
+ await controller.initialize({});
74
+ } catch (error) {
75
+ // Saved storage unusable: environment configuration (if any) still applies.
76
+ return {
77
+ ...(controller.client ? { client: controller.client } : {}),
78
+ warning: error instanceof Error ? error.message : String(error),
79
+ };
80
+ }
81
+ return controller.client ? { client: controller.client } : {};
82
+ } catch (error) {
83
+ return { warning: error instanceof Error ? error.message : String(error) };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Reuse the six harness tool factories unchanged. One MCP server process is
89
+ * one session: limits, cache and counters live as long as the connection.
90
+ */
91
+ export async function createMcpTools(options: McpToolOptions): Promise<{
92
+ tools: McpTool[];
93
+ instructions: string;
94
+ configured: boolean;
95
+ warning?: string;
96
+ }> {
97
+ const env = options.env ?? process.env;
98
+ const host = mcpHost();
99
+ const loaded = options.client
100
+ ? { client: options.client }
101
+ : await loadMcpClient(env, options.configDirectory);
102
+ const client = loaded.client;
103
+ const dependencies: ToolDependencies = {
104
+ client,
105
+ host,
106
+ runtime: {
107
+ session: new Session(readSessionLimits(env)),
108
+ guide: new Guide(host),
109
+ },
110
+ exec: options.exec ?? spawnExec,
111
+ };
112
+ const harness = [
113
+ createAskTool(dependencies),
114
+ createAskFilesTool(dependencies),
115
+ createFindFilesTool(dependencies),
116
+ createLocateTool(dependencies),
117
+ createCheckDiffTool(dependencies),
118
+ createSelectTestsTool(dependencies),
119
+ ] as unknown as HarnessTool[];
120
+ let id = 0;
121
+ const tools = harness.map((tool): McpTool => {
122
+ const runsCommands =
123
+ tool.name === "jev_ask" &&
124
+ JSON.stringify(tool.parameters).includes('"command"');
125
+ return {
126
+ name: tool.name,
127
+ title: tool.label,
128
+ description: tool.description,
129
+ inputSchema: JSON.parse(
130
+ JSON.stringify(tool.parameters),
131
+ ) as McpTool["inputSchema"],
132
+ annotations: {
133
+ title: tool.label,
134
+ // Evidence is read-only unless jev_ask may run a shell command.
135
+ readOnlyHint: !runsCommands,
136
+ destructiveHint: runsCommands,
137
+ idempotentHint: false,
138
+ // Evidence is sent to the configured judgment endpoint.
139
+ openWorldHint: true,
140
+ },
141
+ async call(args, signal) {
142
+ const invalid = validationError(tool.parameters, args);
143
+ if (invalid)
144
+ return { content: [{ type: "text", text: invalid }], isError: true };
145
+ const result = await tool.execute(
146
+ `mcp-${++id}`,
147
+ args as never,
148
+ signal,
149
+ undefined,
150
+ { cwd: options.root },
151
+ );
152
+ return { content: result.content };
153
+ },
154
+ };
155
+ });
156
+ const guidelines = harness.flatMap((tool) => tool.promptGuidelines ?? []);
157
+ const instructions = [dependencies.runtime.guide.text, ...guidelines].join(
158
+ "\n\n",
159
+ );
160
+ return {
161
+ tools,
162
+ instructions,
163
+ configured: client !== undefined,
164
+ ...(loaded.warning ? { warning: loaded.warning } : {}),
165
+ };
166
+ }