denpex-mcp 0.2.6 → 0.2.7

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 CHANGED
@@ -19,7 +19,10 @@ straight off disk and hands back the root cause and the fix.
19
19
  Sign in at **[denpex.com/dashboard](https://denpex.com/dashboard) → API Keys** and create a key (`dpx_…`).
20
20
  The key unlocks authenticated cloud diagnosis, your diagnosis history, quota, and fleet
21
21
  incidents. *Without a key, `diagnose_crash` uses the same canonical anonymous engine as
22
- the homepage's three free diagnoses. Set `DENPEX_LOCAL=1` explicitly for zero-egress;
22
+ the homepage's three free diagnoses. Anonymous clarifications reuse the server-issued,
23
+ log-bound refinement token automatically (or accept it explicitly after an MCP restart),
24
+ so answering Denpex does not spend another diagnosis and cannot be forged with a public
25
+ boolean header. Set `DENPEX_LOCAL=1` explicitly for zero-egress;
23
26
  `lookup_failure` and `analyze_cascade` remain available from the bundled offline data.*
24
27
 
25
28
  ### 2. Add it to your client
@@ -103,7 +106,7 @@ errors is cloud-only and simply unavailable offline.
103
106
 
104
107
  | Tool | What it does | Needs |
105
108
  |------|--------------|-------|
106
- | `diagnose_crash` | Root cause + escalating fix from `logs` / `file` / `dir`. Also accepts `telemetry` (dmesg / nvidia-smi / ibstat), `codeContext` and `topologyMap`. | canonical cloud by default; explicit offline mode |
109
+ | `diagnose_crash` | Root cause + escalating fix from `logs` / `file` / `dir`. Also accepts `telemetry` (dmesg / nvidia-smi / ibstat), `codeContext`, `topologyMap`, typed `operatorAnswers`, and (after an anonymous MCP restart) the prior `refinementToken`. | canonical cloud by default; explicit offline mode |
107
110
  | `lookup_failure` | Search the bundled encyclopedia (300+ classes) by code/keyword/symptom. | works offline |
108
111
  | `analyze_cascade` | Find the culprit rank in a distributed hang; route Xid faults. | works offline |
109
112
  | `submit_fix_outcome` | Report whether a fix worked. **This is what makes the next diagnosis better.** | API key |
@@ -154,7 +157,7 @@ Two fields deserve attention when you write the agent loop:
154
157
  ```bash
155
158
  npm install
156
159
  npm run build # regenerates the offline corpus, then bundles dist/index.js
157
- npm test # 88 unit + protocol-integration tests
160
+ npm test # 111 unit + protocol-integration tests
158
161
  npm run inspector # interactive protocol inspector (MCP Inspector)
159
162
  ```
160
163
 
package/dist/index.js CHANGED
@@ -106,6 +106,7 @@ var DenpexClient = class {
106
106
  const headers = { Accept: "application/json" };
107
107
  if (this.config.apiKey) headers["X-Denpex-Key"] = this.config.apiKey;
108
108
  if (body !== void 0) headers["Content-Type"] = "application/json";
109
+ Object.assign(headers, opts.headers ?? {});
109
110
  let res;
110
111
  try {
111
112
  res = await fetch(`${this.config.apiUrl}${path2}`, {
@@ -139,6 +140,7 @@ var DenpexClient = class {
139
140
 
140
141
  // src/tools/diagnose.ts
141
142
  import { z as z2 } from "zod";
143
+ import { createHash } from "crypto";
142
144
 
143
145
  // src/logs.ts
144
146
  import { promises as fs } from "fs";
@@ -698,6 +700,9 @@ function viewFromCloud(payload, note) {
698
700
  evidence: asArray(fix.evidence),
699
701
  clarifying: asArray(fix.clarifyingQuestions),
700
702
  clarifyingChoices: objArray(fix.clarifyingChoices),
703
+ evidenceRequests: objArray(fix.evidenceRequests),
704
+ awaitingEvidence: fix.awaitingEvidence === true,
705
+ refinementToken: str(payload.refinementToken) ?? void 0,
701
706
  advisories: [
702
707
  ...advisory.map((a) => `${(a.summary ?? "Advisory").trim()}${a.action ? ` \u2014 ${a.action}` : ""}`),
703
708
  ...advisoriesFromAdvanced(payload.advanced)
@@ -742,6 +747,8 @@ function viewFromLocal(r, note) {
742
747
  evidence: r.evidence ?? [],
743
748
  clarifying: [],
744
749
  clarifyingChoices: [],
750
+ evidenceRequests: [],
751
+ awaitingEvidence: false,
745
752
  advisories: (r.detectors ?? []).map((d) => `${d.severity.toUpperCase()} \u2014 ${d.summary}`),
746
753
  // The offline engine has no advisory array, no falsification contracts, no
747
754
  // community/research grounding and no ranked-hypotheses ledger. Emit the fields as
@@ -846,6 +853,14 @@ function renderDiagnosis(v) {
846
853
  }
847
854
  if (v.priorIncident) lines.push("", `**You hit this before:** ${v.priorIncident}`);
848
855
  if (v.costHeadline) lines.push("", `**Cost:** ${v.costHeadline}`);
856
+ if (v.evidenceRequests.length) {
857
+ lines.push("", "**Collect this evidence next (the selection itself was not treated as evidence):**");
858
+ for (const request of v.evidenceRequests) {
859
+ if (request.label) lines.push(`- **${request.label}**${request.why ? ` \u2014 ${request.why}` : ""}`);
860
+ if (request.command) lines.push("```bash", request.command, "```");
861
+ }
862
+ lines.push("Paste the complete output back into the original log evidence and call `diagnose_crash` again.");
863
+ }
849
864
  if (v.clarifyingChoices.length) {
850
865
  lines.push("", "**To narrow it down, answer any of these:**");
851
866
  for (const q of v.clarifyingChoices) {
@@ -856,13 +871,14 @@ function renderDiagnosis(v) {
856
871
  const rec = o.recommended && q.kind === "action" ? " **(Recommended)**" : "";
857
872
  const detail = o.detail ? ` \u2014 ${o.detail}` : "";
858
873
  lines.push(`- \`${o.id ?? o.label}\` ${o.label}${rec}${detail}`);
874
+ if (o.command) lines.push(` - Evidence command: \`${o.command}\``);
859
875
  }
860
876
  if (q.kind === "fact") {
861
877
  lines.push(` _Answer only from what was actually observed. If it is not known, say so rather than guessing \u2014 a wrong answer here sends the diagnosis the wrong way._`);
862
878
  }
863
879
  if (q.command) lines.push(` _Recommended check:_ \`${q.command}\``);
864
880
  }
865
- lines.push("", "_Reply with `ask_followup`, quoting the option id or your own answer._");
881
+ lines.push("", "_Call `diagnose_crash` again with the same original logs and an `operatorAnswers` array containing questionId, optionId, kind, question, and answer. Send the full answer history on every round. The anonymous refinement token is cached automatically; if the MCP process restarts, pass the `refinementToken` from structured output._");
866
882
  } else if (v.clarifying.length) {
867
883
  lines.push("", "**To narrow it down, tell me:**");
868
884
  v.clarifying.forEach((q) => lines.push(`- ${q}`));
@@ -930,11 +946,22 @@ var diagnosisOutputShape = {
930
946
  id: z.string().optional(),
931
947
  label: z.string().optional(),
932
948
  detail: z.string().optional(),
933
- recommended: z.boolean().optional()
949
+ recommended: z.boolean().optional(),
950
+ command: z.string().optional()
934
951
  })).optional(),
935
952
  allowOther: z.boolean().optional(),
936
953
  command: z.string().optional().describe("The exact command behind the recommended option.")
937
- })).optional().describe("Structured form of `clarifying`: pick an option id and send it to `ask_followup`. Answer FACT questions only from what was actually observed \u2014 say you do not know rather than guessing."),
954
+ })).optional().describe("Structured form of `clarifying`: call `diagnose_crash` again with the same logs plus a typed `operatorAnswers` array. Answer FACT questions only from what was observed; selecting an ACTION is intent to collect evidence, not the check result."),
955
+ evidenceRequests: z.array(z.object({
956
+ questionId: z.string().optional(),
957
+ optionId: z.string().optional(),
958
+ label: z.string().optional(),
959
+ command: z.string().optional(),
960
+ why: z.string().optional(),
961
+ pasteBack: z.string().optional()
962
+ })).describe("Exact evidence checks selected by the operator. Run them and paste back the actual output; the selection was not counted as evidence."),
963
+ awaitingEvidence: z.boolean().optional(),
964
+ refinementToken: z.string().optional().describe("Anonymous-only capability for refining this exact original log. Normally cached by the MCP server; pass it back after a process restart."),
938
965
  advisories: z.array(z.string()).describe("Human-readable one-liners, severity-sorted."),
939
966
  advisory: z.array(advisorySchema).describe("Structured cross-cutting advisories, severity-sorted (critical first)."),
940
967
  contradictions: z.array(z.string()).describe("What the engine ruled OUT and why \u2014 the falsification trail."),
@@ -990,6 +1017,19 @@ function fmtDate(ms) {
990
1017
  }
991
1018
 
992
1019
  // src/tools/diagnose.ts
1020
+ var anonymousRefinementTokens = /* @__PURE__ */ new Map();
1021
+ function refinementKey(logs) {
1022
+ return createHash("sha256").update(logs).digest("hex");
1023
+ }
1024
+ function rememberRefinement(key, token) {
1025
+ anonymousRefinementTokens.delete(key);
1026
+ anonymousRefinementTokens.set(key, token);
1027
+ while (anonymousRefinementTokens.size > 50) {
1028
+ const oldest = anonymousRefinementTokens.keys().next().value;
1029
+ if (!oldest) break;
1030
+ anonymousRefinementTokens.delete(oldest);
1031
+ }
1032
+ }
993
1033
  function registerDiagnose(server, ctx) {
994
1034
  server.registerTool(
995
1035
  "diagnose_crash",
@@ -1014,17 +1054,29 @@ function registerDiagnose(server, ctx) {
1014
1054
  "Hardware telemetry captured from the failing node. Keys the engine reads: `dmesg` (output of `dmesg -T`), `nvidia_smi` (`nvidia-smi -q`), `ibstat`. Sending these turns an ambiguous NCCL timeout into a specific Xid/ECC verdict \u2014 and, just as importantly, lets the engine EXONERATE the GPU when the counters are clean."
1015
1055
  ),
1016
1056
  codeContext: z2.record(z2.string()).optional().describe("Relevant source files as { path: contents }. Only send real files you read \u2014 the engine never fabricates code context, and neither should you."),
1017
- topologyMap: z2.record(z2.array(z2.string())).optional().describe("Rank \u2192 node placement as { nodeName: [rank, ...] }, e.g. from the launcher. Lets the engine map a failing rank to a physical node and GPU.")
1057
+ topologyMap: z2.record(z2.array(z2.string())).optional().describe("Rank \u2192 node placement as { nodeName: [rank, ...] }, e.g. from the launcher. Lets the engine map a failing rank to a physical node and GPU."),
1058
+ operatorAnswers: z2.array(z2.object({
1059
+ questionId: z2.string().optional(),
1060
+ optionId: z2.string().optional(),
1061
+ kind: z2.enum(["fact", "action"]).optional(),
1062
+ question: z2.string().optional(),
1063
+ answer: z2.string()
1064
+ })).max(12).optional().describe("Answers from a previous diagnose_crash result. Send the full answer history with the same original logs. Selecting an ACTION asks Denpex for that evidence; it is not treated as the result of the check."),
1065
+ refinementToken: z2.string().max(160).optional().describe("Anonymous-only capability returned by the prior diagnosis. Normally cached automatically in this MCP process; pass it explicitly after a process restart.")
1018
1066
  },
1019
1067
  outputSchema: diagnosisOutputShape
1020
1068
  },
1021
- async ({ logs, file, dir, jobName, runtimeHours, gpuRate, telemetry, codeContext, topologyMap }) => {
1069
+ async ({ logs, file, dir, jobName, runtimeHours, gpuRate, telemetry, codeContext, topologyMap, operatorAnswers, refinementToken }) => {
1022
1070
  try {
1023
1071
  const resolved = await resolveLogs({ logs, file, dir });
1024
1072
  assertEnoughLog(resolved.text);
1025
1073
  const prepared = applyPrivacy(condenseLogs(resolved.text), ctx.config.privacy);
1074
+ const preparedRefinementKey = refinementKey(prepared);
1026
1075
  const sourceNote = resolved.source === "inline" ? void 0 : `Read \`${resolved.source}\`.`;
1027
1076
  if (ctx.config.local) {
1077
+ if (operatorAnswers?.length) {
1078
+ return toolText("Offline mode cannot re-run the evidence-bound conversational reasoner. Run the selected check, append its actual output to the original logs, and call `diagnose_crash` again. No action selection was treated as evidence.", true);
1079
+ }
1028
1080
  const result = localDiagnose(prepared);
1029
1081
  const localView = viewFromLocal(result, sourceNote);
1030
1082
  return toolResult(renderDiagnosis(localView), structuredDiagnosis(localView));
@@ -1045,6 +1097,10 @@ function registerDiagnose(server, ctx) {
1045
1097
  const preparedCodeContext = codeContext ? scrubKeys(codeContext, (v) => scrub(String(v))) : void 0;
1046
1098
  const preparedTopologyMap = topologyMap ? scrubKeys(topologyMap, (ranks) => (Array.isArray(ranks) ? ranks : []).map((r) => scrub(String(r)))) : void 0;
1047
1099
  const endpoint = ctx.config.apiKey ? "/api/diagnose" : "/api/diagnose/anon";
1100
+ const activeRefinementToken = refinementToken ?? anonymousRefinementTokens.get(preparedRefinementKey);
1101
+ if (operatorAnswers?.length && !ctx.config.apiKey && !activeRefinementToken) {
1102
+ return toolText("Anonymous clarification is missing the refinement token from the prior diagnosis. Re-run diagnose_crash with the same original logs, then answer the questions it returns. No answer was submitted or charged.", true);
1103
+ }
1048
1104
  const payload = await ctx.client.request(endpoint, {
1049
1105
  method: "POST",
1050
1106
  body: {
@@ -1054,13 +1110,23 @@ function registerDiagnose(server, ctx) {
1054
1110
  gpuRate,
1055
1111
  ...preparedTelemetry ? { telemetry: preparedTelemetry } : {},
1056
1112
  ...preparedCodeContext ? { codeContext: preparedCodeContext } : {},
1057
- ...preparedTopologyMap ? { topologyMap: preparedTopologyMap } : {}
1113
+ ...preparedTopologyMap ? { topologyMap: preparedTopologyMap } : {},
1114
+ ...operatorAnswers?.length ? { operatorAnswers } : {}
1058
1115
  },
1116
+ ...operatorAnswers?.length && !ctx.config.apiKey && activeRefinementToken ? {
1117
+ headers: {
1118
+ "X-Denpex-Followup": "1",
1119
+ "X-Denpex-Refinement": activeRefinementToken
1120
+ }
1121
+ } : {},
1059
1122
  // The server budgets up to 290s for the deep/LLM path. Keep a small
1060
1123
  // transport buffer so MCP never discards a result that completed inside
1061
1124
  // the server's supported window.
1062
1125
  timeoutMs: 32e4
1063
1126
  });
1127
+ if (!ctx.config.apiKey && typeof payload.refinementToken === "string" && payload.refinementToken) {
1128
+ rememberRefinement(preparedRefinementKey, payload.refinementToken);
1129
+ }
1064
1130
  const view = viewFromCloud(payload, sourceNote);
1065
1131
  return toolResult(renderDiagnosis(view), structuredDiagnosis(view));
1066
1132
  } catch (e) {
@@ -1758,7 +1824,7 @@ function registerPrompts(server) {
1758
1824
 
1759
1825
  // src/version.ts
1760
1826
  var SERVER_NAME = "denpex";
1761
- var SERVER_VERSION = "0.2.6";
1827
+ var SERVER_VERSION = "0.2.7";
1762
1828
 
1763
1829
  // src/server.ts
1764
1830
  function createServer() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "denpex-mcp",
3
- "version": "0.2.6",
4
- "description": "Denpex MCP server \u00e2\u20ac\u201d diagnose GPU/ML training failures (CUDA OOM, NCCL timeouts, Xid faults, NaN loss) from Claude Desktop, Cursor, or Claude Code, with optional explicit zero-egress mode.",
3
+ "version": "0.2.7",
4
+ "description": "Denpex MCP server — diagnose GPU/ML training failures (CUDA OOM, NCCL timeouts, Xid faults, NaN loss) from Claude Desktop, Cursor, or Claude Code, with optional explicit zero-egress mode.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Denpex",