evrex-mcp 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +199 -91
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -49,7 +49,7 @@ Reload Cursor afterwards so it picks up the new server.
49
49
  | Variable | Required | Purpose |
50
50
  |---|---|---|
51
51
  | `EVREX_TOKEN` | **yes** | Your evrex API token. The server exits at startup with instructions if it's missing. |
52
- | `ANTHROPIC_API_KEY` | no | Only used when `EVREX_SYNTHESIZE` is on. Not needed otherwise. |
52
+ | `MODEL_API_KEY` | no | Only used when `EVREX_SYNTHESIZE` is on. Anthropic, OpenAI or Google — the provider is recognised from the key prefix (`sk-ant-…`, `sk-…`, `AIza…`). `ANTHROPIC_API_KEY` is still accepted. |
53
53
  | `EVREX_SYNTHESIZE` | no | Set to `1` to have the server summarize evidence into a prose answer before returning it. **Off by default** — see below. |
54
54
  | `EVREX_API_BASE_URL` | no | Point at a self-hosted evrex backend. Defaults to the hosted one. |
55
55
  | `ANTHROPIC_MODEL` | no | Defaults to `claude-opus-5`. |
package/dist/index.js CHANGED
@@ -55,84 +55,10 @@ var evrexApi = {
55
55
 
56
56
  // ../../packages/llm-core/src/client.ts
57
57
  import Anthropic from "@anthropic-ai/sdk";
58
- var DEFAULT_MODEL = "claude-opus-5";
59
58
  var DEFAULT_MAX_TOKENS = 16e3;
60
- function createAnthropicClient(apiKey) {
61
- if (!apiKey || apiKey.trim().length === 0) return null;
62
- return new Anthropic({ apiKey });
63
- }
64
59
 
65
60
  // ../../packages/llm-core/src/complete.ts
66
61
  import Anthropic2 from "@anthropic-ai/sdk";
67
- var NOOP_LOGGER = { warn: () => void 0, error: () => void 0 };
68
- function parseableFormat(schema) {
69
- return {
70
- type: "json_schema",
71
- schema,
72
- parse: (content) => JSON.parse(content)
73
- };
74
- }
75
- function textOf(message) {
76
- return message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
77
- }
78
- function describe(err) {
79
- if (err instanceof Anthropic2.RateLimitError) {
80
- return `rate limited (${err.status}): ${err.message}`;
81
- }
82
- if (err instanceof Anthropic2.APIConnectionError) {
83
- return `could not reach the provider: ${err.message}`;
84
- }
85
- if (err instanceof Anthropic2.APIError) {
86
- return `provider returned ${err.status ?? "an error"}: ${err.message}`;
87
- }
88
- return String(err);
89
- }
90
- async function completeJson(client2, options) {
91
- if (!client2) return null;
92
- const logger = options.logger ?? NOOP_LOGGER;
93
- const model = options.model ?? DEFAULT_MODEL;
94
- const max_tokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
95
- const output_config = {};
96
- if (options.schema) output_config.format = parseableFormat(options.schema);
97
- if (options.effort) output_config.effort = options.effort;
98
- const request = {
99
- model,
100
- max_tokens,
101
- system: options.system,
102
- messages: [{ role: "user", content: options.user }],
103
- ...Object.keys(output_config).length > 0 ? { output_config } : {}
104
- };
105
- let raw;
106
- try {
107
- if (options.schema) {
108
- const message = await client2.messages.parse(request);
109
- raw = message.parsed_output;
110
- if (raw === null || raw === void 0) {
111
- logger.warn(
112
- "Model returned no parseable JSON for a schema-constrained call."
113
- );
114
- return null;
115
- }
116
- } else {
117
- const message = await client2.messages.create(request);
118
- const text = textOf(message).trim();
119
- if (!text) return null;
120
- raw = JSON.parse(text);
121
- }
122
- } catch (err) {
123
- logger.error(`Model call failed: ${describe(err)}`);
124
- return null;
125
- }
126
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
127
- logger.warn("Model returned a non-object JSON body; discarding it.");
128
- return null;
129
- }
130
- if (options.validate && !options.validate(raw)) {
131
- logger.warn("Model response violated the expected schema; discarding it.");
132
- return null;
133
- }
134
- return raw;
135
- }
136
62
 
137
63
  // ../../packages/llm-core/src/extraction.ts
138
64
  var MAX_ITEMS_PER_CATEGORY = 6;
@@ -188,16 +114,26 @@ function isValidSynthesis(result, evidenceCount) {
188
114
  );
189
115
  }
190
116
  async function synthesizeAnswer(question, evidence, client2, options = {}) {
191
- const raw = await completeJson(client2, {
192
- system: SYNTHESIS_SYSTEM_PROMPT,
193
- user: buildSynthesisPrompt(question, evidence),
194
- schema: SYNTHESIS_SCHEMA,
195
- validate: (value) => typeof value === "object" && value !== null && isValidSynthesis(value, evidence.length),
196
- logger: options.logger,
197
- model: options.model,
198
- effort: options.effort
199
- });
200
- if (!raw) return null;
117
+ if (!client2) return null;
118
+ let body;
119
+ try {
120
+ body = await client2.completeJson({
121
+ system: SYNTHESIS_SYSTEM_PROMPT,
122
+ user: buildSynthesisPrompt(question, evidence),
123
+ schema: SYNTHESIS_SCHEMA,
124
+ model: options.model
125
+ });
126
+ } catch (err) {
127
+ options.logger?.error(
128
+ `synthesis call failed: ${err instanceof Error ? err.message : String(err)}`
129
+ );
130
+ return null;
131
+ }
132
+ if (typeof body !== "object" || body === null || !isValidSynthesis(body, evidence.length)) {
133
+ options.logger?.warn("synthesis response cited evidence it was not given");
134
+ return null;
135
+ }
136
+ const raw = body;
201
137
  return {
202
138
  sentences: raw.sentences ?? [],
203
139
  sentenceEvidence: raw.sentenceEvidence ?? [],
@@ -205,8 +141,169 @@ async function synthesizeAnswer(question, evidence, client2, options = {}) {
205
141
  };
206
142
  }
207
143
 
144
+ // ../../packages/llm-core/src/providers.ts
145
+ var DEFAULT_MODELS = {
146
+ anthropic: "claude-opus-5",
147
+ openai: "gpt-4o",
148
+ google: "gemini-2.0-flash"
149
+ };
150
+ function detectProvider(key) {
151
+ const k = (key ?? "").trim();
152
+ if (!k) return null;
153
+ if (k.startsWith("sk-ant-")) return "anthropic";
154
+ if (k.startsWith("AIza")) return "google";
155
+ if (k.startsWith("sk-")) return "openai";
156
+ return null;
157
+ }
158
+
159
+ // ../../packages/llm-core/src/provider-clients.ts
160
+ import Anthropic3 from "@anthropic-ai/sdk";
161
+ var NOOP = { warn: () => void 0, error: () => void 0 };
162
+ function parseJsonBody(text) {
163
+ const trimmed = text.trim();
164
+ const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(trimmed);
165
+ const body = fenced ? fenced[1] : trimmed;
166
+ try {
167
+ return JSON.parse(body);
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+ var AnthropicClient = class {
173
+ constructor(key, model, logger) {
174
+ this.model = model;
175
+ this.logger = logger;
176
+ this.sdk = new Anthropic3({ apiKey: key });
177
+ }
178
+ provider = "anthropic";
179
+ sdk;
180
+ async completeJson(request) {
181
+ try {
182
+ const message = await this.sdk.messages.create({
183
+ model: request.model ?? this.model,
184
+ max_tokens: request.maxTokens ?? DEFAULT_MAX_TOKENS,
185
+ system: request.system,
186
+ messages: [{ role: "user", content: request.user }],
187
+ ...request.schema ? {
188
+ output_config: {
189
+ format: { type: "json_schema", schema: request.schema }
190
+ }
191
+ } : {}
192
+ });
193
+ const text = message.content.filter((b) => b.type === "text").map((b) => b.text).join("");
194
+ return parseJsonBody(text);
195
+ } catch (err) {
196
+ this.logger.warn(`anthropic call failed: ${describeError(err)}`);
197
+ return null;
198
+ }
199
+ }
200
+ };
201
+ var OpenAiClient = class {
202
+ constructor(key, model, logger) {
203
+ this.key = key;
204
+ this.model = model;
205
+ this.logger = logger;
206
+ }
207
+ provider = "openai";
208
+ async completeJson(request) {
209
+ try {
210
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
211
+ method: "POST",
212
+ headers: {
213
+ "Content-Type": "application/json",
214
+ Authorization: `Bearer ${this.key}`
215
+ },
216
+ body: JSON.stringify({
217
+ model: request.model ?? this.model,
218
+ messages: [
219
+ { role: "system", content: request.system },
220
+ { role: "user", content: request.user }
221
+ ],
222
+ ...request.schema ? {
223
+ response_format: {
224
+ type: "json_schema",
225
+ json_schema: {
226
+ name: "evrex_result",
227
+ strict: true,
228
+ schema: request.schema
229
+ }
230
+ }
231
+ } : { response_format: { type: "json_object" } }
232
+ })
233
+ });
234
+ if (!res.ok) {
235
+ this.logger.warn(
236
+ `openai call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
237
+ );
238
+ return null;
239
+ }
240
+ const body = await res.json();
241
+ const text = body.choices?.[0]?.message?.content;
242
+ return typeof text === "string" ? parseJsonBody(text) : null;
243
+ } catch (err) {
244
+ this.logger.warn(`openai call failed: ${describeError(err)}`);
245
+ return null;
246
+ }
247
+ }
248
+ };
249
+ var GoogleClient = class {
250
+ constructor(key, model, logger) {
251
+ this.key = key;
252
+ this.model = model;
253
+ this.logger = logger;
254
+ }
255
+ provider = "google";
256
+ async completeJson(request) {
257
+ const model = request.model ?? this.model;
258
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;
259
+ const shape = request.schema ? `
260
+
261
+ Respond with JSON matching exactly this schema:
262
+ ${JSON.stringify(request.schema)}` : "";
263
+ try {
264
+ const res = await fetch(url, {
265
+ method: "POST",
266
+ headers: {
267
+ "Content-Type": "application/json",
268
+ "x-goog-api-key": this.key
269
+ },
270
+ body: JSON.stringify({
271
+ systemInstruction: { parts: [{ text: request.system + shape }] },
272
+ contents: [{ role: "user", parts: [{ text: request.user }] }],
273
+ generationConfig: { responseMimeType: "application/json" }
274
+ })
275
+ });
276
+ if (!res.ok) {
277
+ this.logger.warn(
278
+ `google call failed: ${res.status} ${(await res.text()).slice(0, 200)}`
279
+ );
280
+ return null;
281
+ }
282
+ const body = await res.json();
283
+ const text = body.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
284
+ return text ? parseJsonBody(text) : null;
285
+ } catch (err) {
286
+ this.logger.warn(`google call failed: ${describeError(err)}`);
287
+ return null;
288
+ }
289
+ }
290
+ };
291
+ function describeError(err) {
292
+ return err instanceof Error ? err.message : String(err);
293
+ }
294
+ function createModelClient(key, options = {}) {
295
+ const provider = detectProvider(key);
296
+ if (!provider || !key) return null;
297
+ const logger = options.logger ?? NOOP;
298
+ const model = options.model ?? DEFAULT_MODELS[provider];
299
+ const trimmed = key.trim();
300
+ if (provider === "anthropic") return new AnthropicClient(trimmed, model, logger);
301
+ if (provider === "openai") return new OpenAiClient(trimmed, model, logger);
302
+ return new GoogleClient(trimmed, model, logger);
303
+ }
304
+
208
305
  // src/synthesize.ts
209
- var ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? void 0;
306
+ var ANTHROPIC_API_KEY = process.env.MODEL_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? void 0;
210
307
  var ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL;
211
308
  var stderrLogger = {
212
309
  warn: (message) => process.stderr.write(`[evrex] ${message}
@@ -216,7 +313,7 @@ var stderrLogger = {
216
313
  };
217
314
  var client = createAnthropicKeyClient();
218
315
  function createAnthropicKeyClient() {
219
- return createAnthropicClient(ANTHROPIC_API_KEY);
316
+ return createModelClient(ANTHROPIC_API_KEY, { logger: stderrLogger });
220
317
  }
221
318
  function synthesisEnabled() {
222
319
  const flag = process.env.EVREX_SYNTHESIZE;
@@ -336,6 +433,12 @@ function toRepoRelative(filePath, root) {
336
433
  }
337
434
  return filePath;
338
435
  }
436
+ function sameFileRef(a, b) {
437
+ if (a === b) return true;
438
+ if (a.endsWith(`/${b}`)) return true;
439
+ if (b.endsWith(`/${a}`)) return true;
440
+ return false;
441
+ }
339
442
  async function resolveRepos() {
340
443
  const override = process.env.EVREX_REPO_PATH;
341
444
  if (override) {
@@ -361,9 +464,14 @@ async function evrexWhy(filePath, question) {
361
464
  const sessions = (await Promise.all(sessionIds.map((id) => evrexApi.session(id)))).filter(
362
465
  (s) => s !== null
363
466
  );
364
- const rejected = sessions.flatMap((s) => s.rejected).slice(0, MAX_ITEMS);
365
- const constraints = sessions.flatMap((s) => s.constraints).slice(0, MAX_ITEMS);
366
- const decisions = sessions.flatMap((s) => s.decisions).slice(0, MAX_ITEMS);
467
+ const target = isAbsolute(filePath) ? toRepoRelative(filePath, root) : filePath;
468
+ const aboutTarget = (item) => (item.files ?? []).some((f) => sameFileRef(toRepoRelative(f, root), target));
469
+ const byRelevance = (items) => [...items].sort(
470
+ (a, b) => Number(aboutTarget(b)) - Number(aboutTarget(a))
471
+ );
472
+ const rejected = byRelevance(sessions.flatMap((s) => s.rejected)).slice(0, MAX_ITEMS);
473
+ const constraints = byRelevance(sessions.flatMap((s) => s.constraints)).slice(0, MAX_ITEMS);
474
+ const decisions = byRelevance(sessions.flatMap((s) => s.decisions)).slice(0, MAX_ITEMS);
367
475
  const parts = [];
368
476
  const heuristic = sessions.some((s) => s.insightsSource === "heuristic");
369
477
  if (rejected.length > 0) {
@@ -472,7 +580,7 @@ server.registerTool(
472
580
  "evrex_why",
473
581
  {
474
582
  title: "Why does this file/change exist?",
475
- description: "Prior reasoning for a file: decisions, hard constraints, and explicitly REJECTED approaches, with sources and confidence. Use whenever a question asks 'why' a file or module is built the way it is, what was already considered and rejected, or what constraints apply to it \u2014 and call it before making a nontrivial edit to that file, to avoid re-proposing something already rejected. Not for 'what does this code do' \u2014 read the file for that.",
583
+ description: "Prior reasoning for a file: decisions, hard constraints, and explicitly REJECTED approaches, with sources and confidence. Call this BEFORE reasoning from scratch about an existing file \u2014 not only when the user says 'why'. Use it when you are about to change, refactor, simplify, or remove existing code; when a design choice looks odd, redundant, or wrong; when you are about to infer a constraint from what the code happens to do; or when you would otherwise say 'probably' or 'this was likely done because'. Cheaper than the alternative: one call returns the recorded answer instead of many tool calls reconstructing a guess from code \u2014 and reconstruction cannot recover a rejected approach at all, since rejected work leaves no trace in the repo. Returns a plain 'no recorded reasoning found' when it has nothing, so the cost of checking is one call. Not for 'what does this code do' \u2014 read the file for that.",
476
584
  inputSchema: {
477
585
  file_path: z.string().describe("Path to the file, relative to the repo root or absolute"),
478
586
  question: z.string().optional().describe("Optional specific question; defaults to a general 'why' query")
@@ -487,7 +595,7 @@ server.registerTool(
487
595
  "evrex_search",
488
596
  {
489
597
  title: "Search Evrex sessions and commits",
490
- description: "Free-text search across this repo's indexed Claude Code/Cursor session history and git commit history \u2014 surfaces prior discussion and reasoning that lives in conversation, not in the code itself, so grep and git log can't find it. Use for open-ended questions like 'has this been discussed before', 'when/why did we start doing X', or 'what do we know about Y' when you don't have one specific file in mind. If you do have a specific file path, prefer evrex_why instead.",
598
+ description: "Free-text search across this repo's indexed agent session history and git commits \u2014 prior discussion and reasoning that lives in conversation, not in code, so grep, git log and git blame cannot find it. Use for open-ended questions ('has this been discussed', 'when/why did we start doing X', 'what do we know about Y'), and before concluding that something was never considered \u2014 absence in the code is not absence of a decision. If you have a specific file path, prefer evrex_why.",
491
599
  inputSchema: {
492
600
  query: z.string().describe("Free-text search query")
493
601
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evrex-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server that gives coding agents the recorded reasoning behind a repo: prior decisions, hard constraints, and approaches already rejected.",
5
5
  "keywords": [
6
6
  "mcp",