evrex-mcp 0.1.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/README.md +72 -0
- package/dist/index.js +517 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# evrex-mcp
|
|
2
|
+
|
|
3
|
+
Gives a coding agent the reasoning behind a repo that the code itself doesn't
|
|
4
|
+
carry: prior decisions, hard constraints, and approaches the team already tried
|
|
5
|
+
and rejected.
|
|
6
|
+
|
|
7
|
+
Your agent reads the code. It cannot read the conversation where someone
|
|
8
|
+
explained *why* the cache was dropped, or the afternoon you spent proving an
|
|
9
|
+
approach doesn't work. So it re-proposes it. This server closes that gap by
|
|
10
|
+
indexing your Claude Code and Cursor session transcripts alongside git history,
|
|
11
|
+
linking sessions to the commits they produced, and serving that back as tools.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
You need an evrex API token — issue one from the evrex dashboard.
|
|
16
|
+
|
|
17
|
+
**Claude Code**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude mcp add evrex --env EVREX_TOKEN=evx_your_token -- npx -y evrex-mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Cursor** — add to `~/.cursor/mcp.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"evrex": {
|
|
29
|
+
"command": "npx",
|
|
30
|
+
"args": ["-y", "evrex-mcp"],
|
|
31
|
+
"env": { "EVREX_TOKEN": "evx_your_token" }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Reload Cursor afterwards so it picks up the new server.
|
|
38
|
+
|
|
39
|
+
## Tools
|
|
40
|
+
|
|
41
|
+
| Tool | Use it for |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `evrex_why` | Why a specific file is built the way it is, and what was already rejected for it. Call it before a nontrivial edit. |
|
|
44
|
+
| `evrex_search` | Open-ended questions across session and commit history — "have we discussed this", "when did we start doing X". |
|
|
45
|
+
| `evrex_commit_context` | The session behind a commit: what was being attempted and what it decided. |
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
| Variable | Required | Purpose |
|
|
50
|
+
|---|---|---|
|
|
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. |
|
|
53
|
+
| `EVREX_API_BASE_URL` | no | Point at a self-hosted evrex backend. Defaults to the hosted one. |
|
|
54
|
+
| `ANTHROPIC_MODEL` | no | Defaults to `claude-opus-5`. |
|
|
55
|
+
|
|
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.
|
|
59
|
+
|
|
60
|
+
## What it does not do
|
|
61
|
+
|
|
62
|
+
- It won't invent reasoning. A file with nothing indexed about it returns "no
|
|
63
|
+
recorded reasoning found" rather than a plausible guess.
|
|
64
|
+
- Extraction done without an Anthropic key falls back to cue-phrase matching,
|
|
65
|
+
and output derived that way is labeled as such — so you can tell a regex hit
|
|
66
|
+
from a model that actually read the conversation.
|
|
67
|
+
- Every answer sentence cites the evidence supporting it. An answer citing
|
|
68
|
+
evidence that wasn't retrieved is rejected rather than shown.
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
// src/tools.ts
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { dirname, isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
12
|
+
|
|
13
|
+
// src/client.ts
|
|
14
|
+
var DEFAULT_API_BASE_URL = "https://evrex-backend-production.up.railway.app";
|
|
15
|
+
var API_BASE_URL = (process.env.EVREX_API_BASE_URL ?? process.env.EVREX_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
16
|
+
var EVREX_TOKEN = process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN ?? null;
|
|
17
|
+
function headers() {
|
|
18
|
+
const base = { "Content-Type": "application/json" };
|
|
19
|
+
if (EVREX_TOKEN) base.Authorization = `Bearer ${EVREX_TOKEN}`;
|
|
20
|
+
return base;
|
|
21
|
+
}
|
|
22
|
+
function describeFailure(method, path, status, statusText) {
|
|
23
|
+
if (status === 401 || status === 403) {
|
|
24
|
+
return EVREX_TOKEN ? `${method} ${path} -> ${status}: the EVREX_TOKEN this server was started with was rejected. It may be revoked or expired.` : `${method} ${path} -> ${status}: this evrex backend requires a credential, but no EVREX_TOKEN is set for this MCP server.`;
|
|
25
|
+
}
|
|
26
|
+
return `${method} ${path} -> ${status} ${statusText}`;
|
|
27
|
+
}
|
|
28
|
+
async function get(path) {
|
|
29
|
+
const res = await fetch(`${API_BASE_URL}${path}`, { headers: headers() });
|
|
30
|
+
if (!res.ok) throw new Error(describeFailure("GET", path, res.status, res.statusText));
|
|
31
|
+
return await res.json();
|
|
32
|
+
}
|
|
33
|
+
async function post(path, body) {
|
|
34
|
+
const res = await fetch(`${API_BASE_URL}${path}`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: headers(),
|
|
37
|
+
body: JSON.stringify(body)
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) throw new Error(describeFailure("POST", path, res.status, res.statusText));
|
|
40
|
+
return await res.json();
|
|
41
|
+
}
|
|
42
|
+
var evrexApi = {
|
|
43
|
+
baseUrl: API_BASE_URL,
|
|
44
|
+
repos: () => get("/repos"),
|
|
45
|
+
commits: (repoPath) => get(`/commits?repoPath=${encodeURIComponent(repoPath)}`),
|
|
46
|
+
commit: (sha) => get(`/commits/${sha}`).catch(() => null),
|
|
47
|
+
sessions: (repoPath) => get(`/sessions?repoPath=${encodeURIComponent(repoPath)}`),
|
|
48
|
+
session: (id) => get(`/sessions/${id}`).catch(() => null),
|
|
49
|
+
ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
|
|
50
|
+
// Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
|
|
51
|
+
// apps/backend/src/query/query.service.ts#search. Used by evrex_search,
|
|
52
|
+
// which wants ranked hits fast, not a synthesized paragraph.
|
|
53
|
+
search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ../../packages/llm-core/src/client.ts
|
|
57
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
58
|
+
var DEFAULT_MODEL = "claude-opus-5";
|
|
59
|
+
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
|
+
|
|
65
|
+
// ../../packages/llm-core/src/complete.ts
|
|
66
|
+
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
|
+
|
|
137
|
+
// ../../packages/llm-core/src/extraction.ts
|
|
138
|
+
var MAX_ITEMS_PER_CATEGORY = 6;
|
|
139
|
+
var EXTRACTION_SYSTEM_PROMPT = [
|
|
140
|
+
"You extract structured reasoning from a real coding-agent session transcript for Evrex, a tool that recovers WHY code changed, not just what changed.",
|
|
141
|
+
`Each line is labeled "user" (the human engineer actually typed this), "assistant" (the agent), or "tool_result" (raw output from a tool call, e.g. command stdout \u2014 NOT something either party said; never attribute a "tool_result" line's content to "engineer" as raisedBy/source).`,
|
|
142
|
+
"Only extract items explicitly present in the transcript below. Never invent, infer beyond the text, or pad categories with generic filler.",
|
|
143
|
+
"If a category has nothing genuinely present, return an empty array for it \u2014 an empty result is correct and expected, not a failure.",
|
|
144
|
+
`Cap each array at ${MAX_ITEMS_PER_CATEGORY} items \u2014 pick the most consequential ones.`,
|
|
145
|
+
'"atMessage" is the [N] index of the transcript line the item came from.',
|
|
146
|
+
'For a rejected approach, "reason" is why it was turned down and "tradeoff" is what was given up by not taking it. Fill "tradeoff" from the transcript whenever the cost is stated or clearly implied; leave it empty only when the transcript genuinely says nothing about it.'
|
|
147
|
+
].join(" ");
|
|
148
|
+
|
|
149
|
+
// ../../packages/llm-core/src/synthesis.ts
|
|
150
|
+
var MIN_SENTENCES = 1;
|
|
151
|
+
var MAX_SENTENCES = 4;
|
|
152
|
+
var SYNTHESIS_SYSTEM_PROMPT = `You answer questions about why code changed, using ONLY the evidence provided below. Never state a claim that is not directly supported by the evidence text. Respond with a JSON object: {"sentences": string[], "sentenceEvidence": number[][], "noRecordedReasoning": boolean}. Write 2-4 sentences when the evidence supports an answer; each sentence's entry in "sentenceEvidence" lists only the evidence indices (0-based) that directly support it. If the evidence is too thin or unrelated to actually answer the question, set "noRecordedReasoning": true and return empty arrays for "sentences" and "sentenceEvidence" \u2014 do not speculate or hedge.`;
|
|
153
|
+
var SYNTHESIS_SCHEMA = {
|
|
154
|
+
type: "object",
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
required: ["sentences", "sentenceEvidence", "noRecordedReasoning"],
|
|
157
|
+
properties: {
|
|
158
|
+
sentences: { type: "array", items: { type: "string" } },
|
|
159
|
+
sentenceEvidence: {
|
|
160
|
+
type: "array",
|
|
161
|
+
items: { type: "array", items: { type: "integer" } }
|
|
162
|
+
},
|
|
163
|
+
noRecordedReasoning: { type: "boolean" }
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
function buildSynthesisPrompt(question, evidence) {
|
|
167
|
+
const list = evidence.map(
|
|
168
|
+
(e, i) => `[${i}] (${e.kind}, confidence ${(e.confidence ?? 1).toFixed(2)}): ${e.excerpt}`
|
|
169
|
+
).join("\n");
|
|
170
|
+
return `Question: ${question}
|
|
171
|
+
|
|
172
|
+
Evidence:
|
|
173
|
+
${list}`;
|
|
174
|
+
}
|
|
175
|
+
function isValidSynthesis(result, evidenceCount) {
|
|
176
|
+
if (result.noRecordedReasoning === true) return true;
|
|
177
|
+
if (!Array.isArray(result.sentences) || !Array.isArray(result.sentenceEvidence)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (result.sentences.length < MIN_SENTENCES || result.sentences.length > MAX_SENTENCES) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
if (result.sentences.length !== result.sentenceEvidence.length) return false;
|
|
184
|
+
return result.sentenceEvidence.every(
|
|
185
|
+
(indices) => Array.isArray(indices) && indices.every(
|
|
186
|
+
(i) => typeof i === "number" && Number.isInteger(i) && i >= 0 && i < evidenceCount
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
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;
|
|
201
|
+
return {
|
|
202
|
+
sentences: raw.sentences ?? [],
|
|
203
|
+
sentenceEvidence: raw.sentenceEvidence ?? [],
|
|
204
|
+
noRecordedReasoning: Boolean(raw.noRecordedReasoning)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/synthesize.ts
|
|
209
|
+
var ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? void 0;
|
|
210
|
+
var ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL;
|
|
211
|
+
var stderrLogger = {
|
|
212
|
+
warn: (message) => process.stderr.write(`[evrex] ${message}
|
|
213
|
+
`),
|
|
214
|
+
error: (message) => process.stderr.write(`[evrex] ${message}
|
|
215
|
+
`)
|
|
216
|
+
};
|
|
217
|
+
var client = createAnthropicKeyClient();
|
|
218
|
+
function createAnthropicKeyClient() {
|
|
219
|
+
return createAnthropicClient(ANTHROPIC_API_KEY);
|
|
220
|
+
}
|
|
221
|
+
function toSynthesisEvidence(evidence) {
|
|
222
|
+
return evidence.map((e) => ({
|
|
223
|
+
kind: e.kind,
|
|
224
|
+
excerpt: e.excerpt,
|
|
225
|
+
confidence: e.provenance.confidence
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
async function synthesize(question, evidence) {
|
|
229
|
+
if (evidence.length === 0) {
|
|
230
|
+
throw new Error("synthesize() requires evidence; check for an empty set before calling.");
|
|
231
|
+
}
|
|
232
|
+
if (!client) {
|
|
233
|
+
return {
|
|
234
|
+
sentences: null,
|
|
235
|
+
reason: "No ANTHROPIC_API_KEY is set for this MCP server, so the evidence below is ranked but not summarized."
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const result = await synthesizeAnswer(question, toSynthesisEvidence(evidence), client, {
|
|
239
|
+
logger: stderrLogger,
|
|
240
|
+
model: ANTHROPIC_MODEL
|
|
241
|
+
});
|
|
242
|
+
if (!result) {
|
|
243
|
+
return {
|
|
244
|
+
sentences: null,
|
|
245
|
+
reason: "Answer synthesis failed or returned an answer citing evidence it was not given, so the evidence below is ranked but not summarized."
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (result.noRecordedReasoning || result.sentences.length === 0) {
|
|
249
|
+
return {
|
|
250
|
+
sentences: null,
|
|
251
|
+
reason: "The retrieved evidence was too thin to answer the question directly, so it is listed below unsummarized."
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return { sentences: result.sentences };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/tools.ts
|
|
258
|
+
var MAX_EXCERPT = 220;
|
|
259
|
+
var MAX_ITEMS = 5;
|
|
260
|
+
function truncate(text, max) {
|
|
261
|
+
return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
|
|
262
|
+
}
|
|
263
|
+
function normalizeRepoRemote(url) {
|
|
264
|
+
const trimmed = url.trim();
|
|
265
|
+
if (!trimmed) return null;
|
|
266
|
+
let host;
|
|
267
|
+
let path;
|
|
268
|
+
const scpLike = /^(?:[^/@]+@)?([^/:@]+\.[^/:@]+):(?!\/)(.+)$/.exec(trimmed);
|
|
269
|
+
if (scpLike?.[1] && scpLike[2]) {
|
|
270
|
+
host = scpLike[1];
|
|
271
|
+
path = scpLike[2];
|
|
272
|
+
} else {
|
|
273
|
+
let parsed;
|
|
274
|
+
try {
|
|
275
|
+
parsed = new URL(trimmed);
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
if (parsed.protocol === "file:" || !parsed.hostname) return null;
|
|
280
|
+
host = parsed.hostname;
|
|
281
|
+
path = parsed.pathname;
|
|
282
|
+
}
|
|
283
|
+
const cleanedPath = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
284
|
+
if (!cleanedPath) return null;
|
|
285
|
+
return `${host}/${cleanedPath}`.toLowerCase();
|
|
286
|
+
}
|
|
287
|
+
function git(cwd, args) {
|
|
288
|
+
try {
|
|
289
|
+
return execFileSync("git", args, {
|
|
290
|
+
cwd,
|
|
291
|
+
maxBuffer: 1024 * 1024 * 8,
|
|
292
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
293
|
+
}).toString("utf-8").trim();
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function gitRootFor(dir) {
|
|
299
|
+
const root = git(dir, ["rev-parse", "--show-toplevel"]);
|
|
300
|
+
return root ? root : null;
|
|
301
|
+
}
|
|
302
|
+
function repoIdForRoot(root) {
|
|
303
|
+
const origin = git(root, ["remote", "get-url", "origin"]);
|
|
304
|
+
const remotes = origin ? [origin] : (git(root, ["remote"]) ?? "").split("\n").map((n) => n.trim()).filter(Boolean).sort().map((name) => git(root, ["remote", "get-url", name])).filter((url) => !!url);
|
|
305
|
+
for (const url of remotes) {
|
|
306
|
+
const normalized = normalizeRepoRemote(url);
|
|
307
|
+
if (normalized) return `remote:${normalized}`;
|
|
308
|
+
}
|
|
309
|
+
const rootShas = (git(root, ["rev-list", "--max-parents=0", "HEAD"]) ?? "").split("\n").map((s) => s.trim()).filter(Boolean).sort();
|
|
310
|
+
if (rootShas[0]) return `root:${rootShas[0]}`;
|
|
311
|
+
return `path:${root}`;
|
|
312
|
+
}
|
|
313
|
+
function resolveRepoContext(filePath) {
|
|
314
|
+
const override = process.env.EVREX_REPO_PATH;
|
|
315
|
+
if (override) {
|
|
316
|
+
const root2 = gitRootFor(override) ?? override;
|
|
317
|
+
return { repoRef: repoIdForRoot(root2), root: root2 };
|
|
318
|
+
}
|
|
319
|
+
const absolute = isAbsolute(filePath) ? filePath : resolvePath(process.cwd(), filePath);
|
|
320
|
+
const root = gitRootFor(dirname(absolute)) ?? gitRootFor(process.cwd());
|
|
321
|
+
if (!root) return { repoRef: process.cwd(), root: null };
|
|
322
|
+
return { repoRef: repoIdForRoot(root), root };
|
|
323
|
+
}
|
|
324
|
+
function toRepoRelative(filePath, root) {
|
|
325
|
+
if (!root || !isAbsolute(filePath)) return filePath;
|
|
326
|
+
const rel = relative(root, filePath);
|
|
327
|
+
if (rel && !rel.startsWith("..")) return rel;
|
|
328
|
+
try {
|
|
329
|
+
const real = relative(root, realpathSync(filePath));
|
|
330
|
+
if (real && !real.startsWith("..")) return real;
|
|
331
|
+
} catch {
|
|
332
|
+
}
|
|
333
|
+
return filePath;
|
|
334
|
+
}
|
|
335
|
+
async function resolveRepos() {
|
|
336
|
+
const override = process.env.EVREX_REPO_PATH;
|
|
337
|
+
if (override) {
|
|
338
|
+
const { repoRef } = resolveRepoContext(override);
|
|
339
|
+
return [{ id: repoRef, name: repoRef, slug: repoRef, commitCount: 0, sessionCount: 0 }];
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
return await evrexApi.repos();
|
|
343
|
+
} catch {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function evrexWhy(filePath, question) {
|
|
348
|
+
const { repoRef, root } = resolveRepoContext(filePath);
|
|
349
|
+
const text = question ?? `What decisions, constraints, and rejected approaches apply to ${filePath}?`;
|
|
350
|
+
const { evidence } = await evrexApi.search(repoRef, text, [
|
|
351
|
+
toRepoRelative(isAbsolute(filePath) ? filePath : resolvePath(process.cwd(), filePath), root)
|
|
352
|
+
]);
|
|
353
|
+
if (evidence.length === 0) {
|
|
354
|
+
return `No recorded reasoning found for ${filePath}. Nothing in the indexed sessions or commit history covers this file's design.`;
|
|
355
|
+
}
|
|
356
|
+
const sessionIds = [...new Set(evidence.filter((e) => e.kind === "session").map((e) => e.refId))];
|
|
357
|
+
const sessions = (await Promise.all(sessionIds.map((id) => evrexApi.session(id)))).filter(
|
|
358
|
+
(s) => s !== null
|
|
359
|
+
);
|
|
360
|
+
const rejected = sessions.flatMap((s) => s.rejected).slice(0, MAX_ITEMS);
|
|
361
|
+
const constraints = sessions.flatMap((s) => s.constraints).slice(0, MAX_ITEMS);
|
|
362
|
+
const decisions = sessions.flatMap((s) => s.decisions).slice(0, MAX_ITEMS);
|
|
363
|
+
const parts = [];
|
|
364
|
+
const heuristic = sessions.some((s) => s.insightsSource === "heuristic");
|
|
365
|
+
if (rejected.length > 0) {
|
|
366
|
+
parts.push(
|
|
367
|
+
"REJECTED APPROACHES (do not re-propose these without new information):\n" + rejected.map((r) => `- ${r.title} \u2014 ${r.reason}${r.tradeoff ? ` (tradeoff: ${r.tradeoff})` : ""}`).join("\n")
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (constraints.length > 0) {
|
|
371
|
+
parts.push("CONSTRAINTS:\n" + constraints.map((c) => `- [${c.source}] ${c.text}`).join("\n"));
|
|
372
|
+
}
|
|
373
|
+
if (decisions.length > 0) {
|
|
374
|
+
parts.push("PRIOR DECISIONS:\n" + decisions.map((d) => `- ${d.title}: ${d.detail}`).join("\n"));
|
|
375
|
+
}
|
|
376
|
+
if (heuristic && (rejected.length > 0 || constraints.length > 0 || decisions.length > 0)) {
|
|
377
|
+
parts.push(
|
|
378
|
+
"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
|
+
);
|
|
380
|
+
}
|
|
381
|
+
const answer = await synthesize(text, evidence);
|
|
382
|
+
parts.push(answer.sentences ? `ANSWER: ${answer.sentences.join(" ")}` : `ANSWER: ${answer.reason}`);
|
|
383
|
+
const evidenceLines = evidence.slice(0, MAX_ITEMS).map((e) => {
|
|
384
|
+
const conf = e.provenance.status === "verified" ? "verified" : `${Math.round((e.provenance.confidence ?? 0) * 100)}%`;
|
|
385
|
+
return `- [${e.kind} ${conf}] ${truncate(e.excerpt, MAX_EXCERPT)}`;
|
|
386
|
+
});
|
|
387
|
+
parts.push(`EVIDENCE:
|
|
388
|
+
${evidenceLines.join("\n")}`);
|
|
389
|
+
return parts.join("\n\n");
|
|
390
|
+
}
|
|
391
|
+
async function evrexSearch(query) {
|
|
392
|
+
if (!query.trim()) return "Empty query.";
|
|
393
|
+
const repos = await resolveRepos();
|
|
394
|
+
if (repos.length === 0) return "No indexed repos found (GET /repos returned none).";
|
|
395
|
+
const multiRepo = repos.length > 1;
|
|
396
|
+
const perRepo = await Promise.all(
|
|
397
|
+
repos.map(async (repo) => {
|
|
398
|
+
try {
|
|
399
|
+
const { evidence } = await evrexApi.search(repo.id, query);
|
|
400
|
+
return evidence.map((e) => ({ repo, e }));
|
|
401
|
+
} catch {
|
|
402
|
+
return [];
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
);
|
|
406
|
+
const results = perRepo.flat();
|
|
407
|
+
if (results.length === 0) return `No matches for "${query}" across indexed sessions and commits.`;
|
|
408
|
+
const rank = (e) => e.provenance.status === "verified" ? 1 : e.provenance.confidence ?? 0;
|
|
409
|
+
results.sort((a, b) => rank(b.e) - rank(a.e));
|
|
410
|
+
const lines = results.slice(0, MAX_ITEMS * 2).map(({ repo, e }) => {
|
|
411
|
+
const conf = e.provenance.status === "verified" ? "verified" : `${Math.round((e.provenance.confidence ?? 0) * 100)}%`;
|
|
412
|
+
const repoTag = multiRepo ? ` \xB7 ${repo.name}` : "";
|
|
413
|
+
return `- [${e.kind} ${e.refId.slice(0, 8)} ${conf}${repoTag}] ${truncate(e.excerpt.replace(/\s+/g, " ").trim(), MAX_EXCERPT)}`;
|
|
414
|
+
});
|
|
415
|
+
return lines.join("\n");
|
|
416
|
+
}
|
|
417
|
+
async function evrexCommitContext(sha) {
|
|
418
|
+
const commit = await evrexApi.commit(sha);
|
|
419
|
+
if (!commit) return `No commit found for sha ${sha}.`;
|
|
420
|
+
const parts = [
|
|
421
|
+
`COMMIT ${commit.sha.slice(0, 8)}: ${commit.message}${commit.body ? `
|
|
422
|
+
${truncate(commit.body, MAX_EXCERPT)}` : ""}`
|
|
423
|
+
];
|
|
424
|
+
if (commit.link) {
|
|
425
|
+
const session = await evrexApi.session(commit.link.sessionId);
|
|
426
|
+
const status = commit.link.provenance.status === "verified" ? "verified" : "inferred";
|
|
427
|
+
const conf = commit.link.provenance.status === "verified" ? "" : ` (${Math.round((commit.link.provenance.confidence ?? 0) * 100)}%)`;
|
|
428
|
+
parts.push(`LINKED SESSION (${status}${conf}): ${session ? session.intent : commit.link.sessionId}`);
|
|
429
|
+
if (session) {
|
|
430
|
+
if (session.rejected.length > 0) {
|
|
431
|
+
parts.push(
|
|
432
|
+
"REJECTED APPROACHES:\n" + session.rejected.slice(0, MAX_ITEMS).map((r) => `- ${r.title} \u2014 ${r.reason}${r.tradeoff ? ` (tradeoff: ${r.tradeoff})` : ""}`).join("\n")
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
if (session.constraints.length > 0) {
|
|
436
|
+
parts.push(
|
|
437
|
+
"CONSTRAINTS:\n" + session.constraints.slice(0, MAX_ITEMS).map((c) => `- [${c.source}] ${c.text}`).join("\n")
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
parts.push(`OUTCOME: ${truncate(session.outcome, MAX_EXCERPT)}`);
|
|
441
|
+
}
|
|
442
|
+
} else {
|
|
443
|
+
parts.push("No session linked \u2014 no recorded reasoning behind this commit.");
|
|
444
|
+
}
|
|
445
|
+
if (commit.relatedCommitIds.length > 0) {
|
|
446
|
+
const relatedShas = commit.relatedCommitIds.slice(0, MAX_ITEMS);
|
|
447
|
+
const related = (await Promise.all(relatedShas.map((s) => evrexApi.commit(s)))).filter(
|
|
448
|
+
(c) => c !== null
|
|
449
|
+
);
|
|
450
|
+
const lines = related.map((c) => `- ${c.sha.slice(0, 8)}: ${truncate(c.message, 100)}`);
|
|
451
|
+
parts.push(`RELATED COMMITS:
|
|
452
|
+
${lines.join("\n")}`);
|
|
453
|
+
}
|
|
454
|
+
return parts.join("\n\n");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// src/index.ts
|
|
458
|
+
var server = new McpServer({ name: "evrex", version: "0.1.0" });
|
|
459
|
+
server.registerTool(
|
|
460
|
+
"evrex_why",
|
|
461
|
+
{
|
|
462
|
+
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.",
|
|
464
|
+
inputSchema: {
|
|
465
|
+
file_path: z.string().describe("Path to the file, relative to the repo root or absolute"),
|
|
466
|
+
question: z.string().optional().describe("Optional specific question; defaults to a general 'why' query")
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
async ({ file_path, question }) => {
|
|
470
|
+
const text = await evrexWhy(file_path, question);
|
|
471
|
+
return { content: [{ type: "text", text }] };
|
|
472
|
+
}
|
|
473
|
+
);
|
|
474
|
+
server.registerTool(
|
|
475
|
+
"evrex_search",
|
|
476
|
+
{
|
|
477
|
+
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.",
|
|
479
|
+
inputSchema: {
|
|
480
|
+
query: z.string().describe("Free-text search query")
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
async ({ query }) => {
|
|
484
|
+
const text = await evrexSearch(query);
|
|
485
|
+
return { content: [{ type: "text", text }] };
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
server.registerTool(
|
|
489
|
+
"evrex_commit_context",
|
|
490
|
+
{
|
|
491
|
+
title: "Commit context",
|
|
492
|
+
description: "The session, tradeoffs, and related commits behind a specific git commit. Use when asked about the story or rationale behind a specific SHA (from `git log`/`git blame`/a PR). If you have a file path instead of a SHA, prefer evrex_why.",
|
|
493
|
+
inputSchema: {
|
|
494
|
+
sha: z.string().describe("Git commit SHA (full or abbreviated)")
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
async ({ sha }) => {
|
|
498
|
+
const text = await evrexCommitContext(sha);
|
|
499
|
+
return { content: [{ type: "text", text }] };
|
|
500
|
+
}
|
|
501
|
+
);
|
|
502
|
+
function assertConfigured() {
|
|
503
|
+
if (process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN) return;
|
|
504
|
+
console.error(
|
|
505
|
+
"evrex-mcp: EVREX_TOKEN is not set.\n This server needs an evrex API token to read your team's indexed history.\n Issue one from the evrex dashboard, then register the server with it:\n claude mcp add evrex --env EVREX_TOKEN=evx_... -- npx -y evrex-mcp\n See https://www.npmjs.com/package/evrex-mcp for the Cursor equivalent."
|
|
506
|
+
);
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
async function main() {
|
|
510
|
+
assertConfigured();
|
|
511
|
+
const transport = new StdioServerTransport();
|
|
512
|
+
await server.connect(transport);
|
|
513
|
+
}
|
|
514
|
+
main().catch((err) => {
|
|
515
|
+
console.error("evrex-mcp fatal error:", err);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "evrex-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
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"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"evrex-mcp": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": ["dist/index.js", "README.md"],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"bundle": "node scripts/bundle.mjs",
|
|
19
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
20
|
+
"lint": "eslint .",
|
|
21
|
+
"check-types": "tsc --noEmit",
|
|
22
|
+
"test": "tsc -p tsconfig.json && node --test dist/*.test.js",
|
|
23
|
+
"prepack": "pnpm run bundle"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@anthropic-ai/sdk": "^0.72.0",
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.20.1",
|
|
28
|
+
"zod": "^3.24.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@repo/eslint-config": "workspace:*",
|
|
32
|
+
"@repo/llm-core": "workspace:*",
|
|
33
|
+
"@repo/typescript-config": "workspace:*",
|
|
34
|
+
"@types/node": "^24.0.0",
|
|
35
|
+
"esbuild": "^0.25.12",
|
|
36
|
+
"eslint": "^9.39.1",
|
|
37
|
+
"typescript": "^5.9.2"
|
|
38
|
+
}
|
|
39
|
+
}
|