jev-agent-tools 0.1.4 → 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 +70 -1
- package/CONTRIBUTING.md +40 -0
- package/README.md +42 -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 +39 -12
- package/src/constants.ts +11 -0
- package/src/core/command-output.ts +17 -1
- package/src/host.ts +11 -0
- 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/session.ts +59 -0
- package/src/setup.ts +13 -5
- package/src/tools/ask-files.ts +5 -7
- package/src/tools/ask.ts +26 -22
- package/src/tools/check-diff.ts +8 -5
- package/src/tools/docs-check.ts +1 -0
- package/src/tools/find.ts +5 -2
- package/src/tools/locate.ts +5 -8
- package/src/tools/select-tests.ts +7 -4
- package/src/tools/spec-check.ts +1 -0
|
@@ -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
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Envelope } from "./core/output.ts";
|
|
2
|
+
import type { JudgmentOptions } from "./jev/types.ts";
|
|
2
3
|
import type { Result } from "./result.ts";
|
|
3
4
|
|
|
4
5
|
export interface SessionLimits {
|
|
@@ -53,6 +54,10 @@ export class Session {
|
|
|
53
54
|
cacheHits: 0,
|
|
54
55
|
};
|
|
55
56
|
private readonly limits: SessionLimits;
|
|
57
|
+
// USD gate: at most one admitted request in flight under a USD limit.
|
|
58
|
+
private gateHeld = false;
|
|
59
|
+
private gateEpoch = 0;
|
|
60
|
+
private gateWaiters = new Set<() => void>();
|
|
56
61
|
constructor(limits: SessionLimits) {
|
|
57
62
|
this.limits = limits;
|
|
58
63
|
}
|
|
@@ -75,6 +80,56 @@ export class Session {
|
|
|
75
80
|
this.counters.questions += questions;
|
|
76
81
|
return { ok: true };
|
|
77
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Under a USD limit, admit one request at a time so each admission sees the
|
|
85
|
+
* cost reported by every earlier request; without it, concurrent batches are
|
|
86
|
+
* all admitted before any cost arrives and overshoot the limit. Only the
|
|
87
|
+
* final admitted request can then exceed the limit, as documented.
|
|
88
|
+
*
|
|
89
|
+
* `awaitAdmission` reserves the gate atomically: the availability check and
|
|
90
|
+
* `gateHeld = true` run with no await between them, so two callers cannot
|
|
91
|
+
* both pass. It resolves to a release function that the client calls on
|
|
92
|
+
* every exit path (response, refusal, failure, abort); release is
|
|
93
|
+
* idempotent and ignores a gate that `reset()` already replaced.
|
|
94
|
+
*/
|
|
95
|
+
requestGate(): Pick<JudgmentOptions, "awaitAdmission"> {
|
|
96
|
+
return {
|
|
97
|
+
awaitAdmission: async (signal) => {
|
|
98
|
+
if (this.limits.maxUsd === undefined) return undefined;
|
|
99
|
+
while (this.gateHeld) {
|
|
100
|
+
signal?.throwIfAborted();
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
const onAbort = () => {
|
|
103
|
+
this.gateWaiters.delete(wake);
|
|
104
|
+
reject(signal?.reason);
|
|
105
|
+
};
|
|
106
|
+
const wake = () => {
|
|
107
|
+
signal?.removeEventListener("abort", onAbort);
|
|
108
|
+
resolve();
|
|
109
|
+
};
|
|
110
|
+
this.gateWaiters.add(wake);
|
|
111
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
signal?.throwIfAborted();
|
|
115
|
+
this.gateHeld = true;
|
|
116
|
+
const epoch = this.gateEpoch;
|
|
117
|
+
let released = false;
|
|
118
|
+
return () => {
|
|
119
|
+
if (released || epoch !== this.gateEpoch) return;
|
|
120
|
+
released = true;
|
|
121
|
+
this.gateHeld = false;
|
|
122
|
+
this.wakeGateWaiters();
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Every waiter rechecks; the first to run reserves, the rest wait again.
|
|
128
|
+
private wakeGateWaiters(): void {
|
|
129
|
+
const waiters = [...this.gateWaiters];
|
|
130
|
+
this.gateWaiters.clear();
|
|
131
|
+
for (const wake of waiters) wake();
|
|
132
|
+
}
|
|
78
133
|
recordUsage(usage: { costUsd: number }): void {
|
|
79
134
|
this.counters.costUsd += usage.costUsd;
|
|
80
135
|
}
|
|
@@ -93,6 +148,10 @@ export class Session {
|
|
|
93
148
|
return { ...this.counters };
|
|
94
149
|
}
|
|
95
150
|
reset(): void {
|
|
151
|
+
// A new session generation: outstanding release functions become no-ops.
|
|
152
|
+
this.gateEpoch++;
|
|
153
|
+
this.gateHeld = false;
|
|
154
|
+
this.wakeGateWaiters();
|
|
96
155
|
this.counters = {
|
|
97
156
|
calls: 0,
|
|
98
157
|
questions: 0,
|
package/src/setup.ts
CHANGED
|
@@ -62,11 +62,11 @@ export class Setup {
|
|
|
62
62
|
)
|
|
63
63
|
return;
|
|
64
64
|
this.offered = true;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
// Host event deadlines bound work, not the time needed to find credentials.
|
|
66
|
+
// Return before waiting for input so omp clears the handler's UI abort timer.
|
|
67
|
+
void this.offer(ctx).catch((error: unknown) => {
|
|
68
|
+
ctx.ui.notify(this.message(error), "error");
|
|
69
|
+
});
|
|
70
70
|
} catch (error) {
|
|
71
71
|
if (this.interactive(ctx)) ctx.ui.notify(this.message(error), "error");
|
|
72
72
|
}
|
|
@@ -102,6 +102,14 @@ export class Setup {
|
|
|
102
102
|
: "Jev configuration failed; the previous configuration was kept.";
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
private async offer(ctx: SetupContext): Promise<void> {
|
|
106
|
+
const choice = await ctx.ui.select(
|
|
107
|
+
"Jev Tools needs an endpoint and API key",
|
|
108
|
+
["Configure now", "Later"],
|
|
109
|
+
);
|
|
110
|
+
if (choice === "Configure now") await this.configure(ctx);
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
private async configure(ctx: SetupContext): Promise<void> {
|
|
106
114
|
if (this.busy) return;
|
|
107
115
|
this.busy = true;
|
package/src/tools/ask-files.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { type Static, Type } from "@sinclair/typebox";
|
|
5
5
|
import { collectAskFiles } from "../adapters/ask-files.ts";
|
|
6
|
+
import { canonicalPath } from "../adapters/canonical-path.ts";
|
|
6
7
|
import { shareGitInventory } from "../adapters/git-inventory.ts";
|
|
7
8
|
import { hostUsage } from "../adapters/usage.ts";
|
|
8
9
|
import { compileAsks, readAsks, reverseQuestions } from "../core/asks.ts";
|
|
@@ -67,6 +68,7 @@ export function createAskFilesTool(dependencies: ToolDependencies) {
|
|
|
67
68
|
ctx: { cwd: string } & GuideContext,
|
|
68
69
|
) {
|
|
69
70
|
const client = dependencies.client;
|
|
71
|
+
const cwd = await canonicalPath(ctx.cwd);
|
|
70
72
|
const started = performance.now();
|
|
71
73
|
const exec = shareGitInventory(execute);
|
|
72
74
|
const totals = {
|
|
@@ -99,12 +101,7 @@ export function createAskFilesTool(dependencies: ToolDependencies) {
|
|
|
99
101
|
if (!client) return finish({ refusal: NOT_CONFIGURED });
|
|
100
102
|
const plan = compileAsks(args.asks, { surface: "files" });
|
|
101
103
|
if (!plan.ok) return finish({ refusal: plan.error });
|
|
102
|
-
const collected = await collectAskFiles(
|
|
103
|
-
ctx.cwd,
|
|
104
|
-
args.paths,
|
|
105
|
-
signal,
|
|
106
|
-
exec,
|
|
107
|
-
);
|
|
104
|
+
const collected = await collectAskFiles(cwd, args.paths, signal, exec);
|
|
108
105
|
if (!collected.ok) return finish({ refusal: collected.error });
|
|
109
106
|
skipped = collected.skipped;
|
|
110
107
|
if (!collected.files.length && skipped.length)
|
|
@@ -115,6 +112,7 @@ export function createAskFilesTool(dependencies: ToolDependencies) {
|
|
|
115
112
|
let budget: BudgetRefusal | undefined;
|
|
116
113
|
const options = {
|
|
117
114
|
signal,
|
|
115
|
+
...runtime.session.requestGate(),
|
|
118
116
|
beforeRequest: (count: number) => {
|
|
119
117
|
if (args.max_calls !== undefined && sent >= args.max_calls) {
|
|
120
118
|
budget = {
|
|
@@ -148,7 +146,7 @@ export function createAskFilesTool(dependencies: ToolDependencies) {
|
|
|
148
146
|
const integrity = checkIntegrity(state, plan.asks, [
|
|
149
147
|
{
|
|
150
148
|
...file.identity,
|
|
151
|
-
insertedPath: resolve(
|
|
149
|
+
insertedPath: resolve(cwd, state.path),
|
|
152
150
|
content: state.content,
|
|
153
151
|
insertedSha256: createHash("sha256")
|
|
154
152
|
.update(state.content)
|