evrex-mcp 0.1.0 → 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 (3) hide show
  1. package/README.md +18 -4
  2. package/dist/index.js +199 -90
  3. package/package.json +12 -3
package/README.md CHANGED
@@ -49,13 +49,27 @@ 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 | Synthesizes retrieved evidence into a direct answer. Without it, tools return ranked evidence excerpts and say why they're unsummarized a working server, just a quieter one. |
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
+ | `EVREX_SYNTHESIZE` | no | Set to `1` to have the server summarize evidence into a prose answer before returning it. **Off by default** — see below. |
53
54
  | `EVREX_API_BASE_URL` | no | Point at a self-hosted evrex backend. Defaults to the hosted one. |
54
55
  | `ANTHROPIC_MODEL` | no | Defaults to `claude-opus-5`. |
55
56
 
56
- Answer synthesis runs **on your machine with your own key**. The evrex backend
57
- holds no model-provider credential, so your transcripts are never sent through
58
- someone else's API account.
57
+ ## No API credits needed to query
58
+
59
+ The tools return the recorded context — constraints, rejected approaches, prior
60
+ decisions, and ranked evidence — and let *your agent* reason over it. Your agent
61
+ is already a model reading this output, so summarizing it first would spend a
62
+ model call and latency to hand it *less* to work with.
63
+
64
+ That means `evrex_why` and friends cost nothing per call. The one place a model
65
+ runs is extraction, once per session when a repo is indexed, on the machine
66
+ that has the transcript — and already-extracted sessions are skipped, so
67
+ re-indexing is nearly free.
68
+
69
+ Set `EVREX_SYNTHESIZE=1` if you're consuming these tools from something with no
70
+ model of its own — a dashboard, a CI bot. That path uses your own key. The
71
+ evrex backend never holds a model-provider credential either way, so your
72
+ transcripts are never sent through someone else's API account.
59
73
 
60
74
  ## What it does not do
61
75
 
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,11 @@ 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 });
317
+ }
318
+ function synthesisEnabled() {
319
+ const flag = process.env.EVREX_SYNTHESIZE;
320
+ return flag === "1" || flag?.toLowerCase() === "true";
220
321
  }
221
322
  function toSynthesisEvidence(evidence) {
222
323
  return evidence.map((e) => ({
@@ -378,8 +479,16 @@ async function evrexWhy(filePath, question) {
378
479
  "NOTE: the blocks above were extracted by cue-phrase matching, not by a model reading the conversation \u2014 they may be incomplete or miss context."
379
480
  );
380
481
  }
381
- const answer = await synthesize(text, evidence);
382
- parts.push(answer.sentences ? `ANSWER: ${answer.sentences.join(" ")}` : `ANSWER: ${answer.reason}`);
482
+ if (synthesisEnabled()) {
483
+ const answer = await synthesize(text, evidence);
484
+ parts.push(
485
+ answer.sentences ? `ANSWER: ${answer.sentences.join(" ")}` : `ANSWER: ${answer.reason}`
486
+ );
487
+ } else {
488
+ parts.push(
489
+ "HOW TO USE THIS: treat the constraints and rejected approaches above as binding \u2014 they are what this team already decided, not suggestions. Do not re-propose a rejected approach unless you have new information that specifically invalidates the stated reason, and say so if you do. Answer the user from the evidence below; if it does not actually cover their question, say that rather than inferring."
490
+ );
491
+ }
383
492
  const evidenceLines = evidence.slice(0, MAX_ITEMS).map((e) => {
384
493
  const conf = e.provenance.status === "verified" ? "verified" : `${Math.round((e.provenance.confidence ?? 0) * 100)}%`;
385
494
  return `- [${e.kind} ${conf}] ${truncate(e.excerpt, MAX_EXCERPT)}`;
@@ -460,7 +569,7 @@ server.registerTool(
460
569
  "evrex_why",
461
570
  {
462
571
  title: "Why does this file/change exist?",
463
- 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.",
572
+ 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.",
464
573
  inputSchema: {
465
574
  file_path: z.string().describe("Path to the file, relative to the repo root or absolute"),
466
575
  question: z.string().optional().describe("Optional specific question; defaults to a general 'why' query")
@@ -475,7 +584,7 @@ server.registerTool(
475
584
  "evrex_search",
476
585
  {
477
586
  title: "Search Evrex sessions and commits",
478
- 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.",
587
+ 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.",
479
588
  inputSchema: {
480
589
  query: z.string().describe("Free-text search query")
481
590
  }
package/package.json CHANGED
@@ -1,15 +1,24 @@
1
1
  {
2
2
  "name": "evrex-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
- "keywords": ["mcp", "claude", "cursor", "evrex", "code-context"],
5
+ "keywords": [
6
+ "mcp",
7
+ "claude",
8
+ "cursor",
9
+ "evrex",
10
+ "code-context"
11
+ ],
6
12
  "license": "MIT",
7
13
  "type": "module",
8
14
  "main": "./dist/index.js",
9
15
  "bin": {
10
16
  "evrex-mcp": "dist/index.js"
11
17
  },
12
- "files": ["dist/index.js", "README.md"],
18
+ "files": [
19
+ "dist/index.js",
20
+ "README.md"
21
+ ],
13
22
  "engines": {
14
23
  "node": ">=20"
15
24
  },