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.
- package/CHANGELOG.md +88 -3
- package/CONTRIBUTING.md +40 -0
- package/README.md +64 -9
- package/SECURITY.md +27 -0
- package/dist/adapters/analysis-context.js +75 -0
- package/dist/adapters/ask-files.js +189 -0
- package/dist/adapters/ask-proof.js +144 -0
- package/dist/adapters/ask-syntax.js +385 -0
- package/dist/adapters/canonical-path.js +17 -0
- package/dist/adapters/command.js +181 -0
- package/dist/adapters/docs.js +172 -0
- package/dist/adapters/exec.js +207 -0
- package/dist/adapters/files.js +293 -0
- package/dist/adapters/find.js +122 -0
- package/dist/adapters/git-base.js +26 -0
- package/dist/adapters/git-inventory.js +71 -0
- package/dist/adapters/git.js +439 -0
- package/dist/adapters/locate-file.js +159 -0
- package/dist/adapters/output-lines.js +46 -0
- package/dist/adapters/private-storage.js +98 -0
- package/dist/adapters/risk-callers.js +426 -0
- package/dist/adapters/runner-version.js +78 -0
- package/dist/adapters/shell.js +76 -0
- package/dist/adapters/syntax.js +187 -0
- package/dist/adapters/test-inventory.js +131 -0
- package/dist/adapters/usage.js +20 -0
- package/dist/adapters/utf8.js +47 -0
- package/dist/configuration.js +257 -0
- package/dist/constants.js +119 -0
- package/dist/core/ask-closure.js +282 -0
- package/dist/core/ask-proof.js +1 -0
- package/dist/core/ask-references.js +194 -0
- package/dist/core/asks.js +436 -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 +120 -0
- package/dist/core/pointer.js +29 -0
- package/dist/core/risk-callers.js +851 -0
- package/dist/core/runner-version.js +45 -0
- package/dist/core/sections.js +230 -0
- package/dist/core/state.js +44 -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 +434 -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 +187 -0
- package/dist/mcp/tools.js +116 -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 +42 -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 +1 -0
- package/dist/texts/ask.js +2 -0
- package/dist/texts/check-diff.js +17 -0
- package/dist/texts/configuration.js +1 -0
- package/dist/texts/find.js +14 -0
- package/dist/texts/guide.js +16 -0
- package/dist/texts/locate.js +10 -0
- package/dist/texts/select-tests.js +2 -0
- package/dist/tools/ask-files.js +217 -0
- package/dist/tools/ask-schema.js +70 -0
- package/dist/tools/ask.js +686 -0
- package/dist/tools/check-diff.js +402 -0
- package/dist/tools/docs-check.js +299 -0
- package/dist/tools/find.js +389 -0
- package/dist/tools/locate.js +303 -0
- package/dist/tools/select-tests.js +567 -0
- package/dist/tools/spec-check.js +166 -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 +91 -0
- package/docs/design.md +3 -3
- package/docs/mcp.md +231 -0
- package/package.json +19 -4
- package/server.json +57 -0
- package/src/adapters/canonical-path.ts +18 -0
- package/src/adapters/command.ts +7 -4
- package/src/adapters/exec.ts +226 -0
- package/src/adapters/private-storage.ts +143 -0
- package/src/adapters/risk-callers.ts +4 -2
- package/src/adapters/shell.ts +97 -0
- package/src/configuration.ts +294 -0
- package/src/constants.ts +11 -0
- package/src/core/command-output.ts +17 -1
- package/src/host-tui.d.ts +14 -0
- package/src/host.ts +11 -0
- package/src/index.ts +13 -5
- package/src/jev/client.ts +12 -0
- package/src/jev/types.ts +6 -0
- package/src/mcp/main.ts +135 -0
- package/src/mcp/protocol.ts +282 -0
- package/src/mcp/tools.ts +166 -0
- package/src/secret-input.ts +222 -0
- package/src/session.ts +59 -0
- package/src/setup.ts +170 -0
- package/src/texts/configuration.ts +1 -1
- package/src/tools/ask-files.ts +8 -13
- package/src/tools/ask.ts +29 -28
- package/src/tools/check-diff.ts +11 -11
- package/src/tools/docs-check.ts +1 -0
- package/src/tools/find.ts +8 -8
- package/src/tools/locate.ts +8 -9
- package/src/tools/select-tests.ts +10 -10
- 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 {
|
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,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
|
+
}
|
package/src/mcp/tools.ts
ADDED
|
@@ -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
|
+
}
|