denpex-mcp 0.2.5 → 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.
Files changed (3) hide show
  1. package/README.md +6 -3
  2. package/dist/index.js +103 -5
  3. package/package.json +1 -1
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";
@@ -697,6 +699,10 @@ function viewFromCloud(payload, note) {
697
699
  steps: asArray(fix.steps),
698
700
  evidence: asArray(fix.evidence),
699
701
  clarifying: asArray(fix.clarifyingQuestions),
702
+ clarifyingChoices: objArray(fix.clarifyingChoices),
703
+ evidenceRequests: objArray(fix.evidenceRequests),
704
+ awaitingEvidence: fix.awaitingEvidence === true,
705
+ refinementToken: str(payload.refinementToken) ?? void 0,
700
706
  advisories: [
701
707
  ...advisory.map((a) => `${(a.summary ?? "Advisory").trim()}${a.action ? ` \u2014 ${a.action}` : ""}`),
702
708
  ...advisoriesFromAdvanced(payload.advanced)
@@ -740,6 +746,9 @@ function viewFromLocal(r, note) {
740
746
  steps: r.steps ?? [],
741
747
  evidence: r.evidence ?? [],
742
748
  clarifying: [],
749
+ clarifyingChoices: [],
750
+ evidenceRequests: [],
751
+ awaitingEvidence: false,
743
752
  advisories: (r.detectors ?? []).map((d) => `${d.severity.toUpperCase()} \u2014 ${d.summary}`),
744
753
  // The offline engine has no advisory array, no falsification contracts, no
745
754
  // community/research grounding and no ranked-hypotheses ledger. Emit the fields as
@@ -844,7 +853,33 @@ function renderDiagnosis(v) {
844
853
  }
845
854
  if (v.priorIncident) lines.push("", `**You hit this before:** ${v.priorIncident}`);
846
855
  if (v.costHeadline) lines.push("", `**Cost:** ${v.costHeadline}`);
847
- if (v.clarifying.length) {
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
+ }
864
+ if (v.clarifyingChoices.length) {
865
+ lines.push("", "**To narrow it down, answer any of these:**");
866
+ for (const q of v.clarifyingChoices) {
867
+ if (!q?.question) continue;
868
+ lines.push("", `_${q.question}_`);
869
+ for (const o of q.options ?? []) {
870
+ if (!o?.label) continue;
871
+ const rec = o.recommended && q.kind === "action" ? " **(Recommended)**" : "";
872
+ const detail = o.detail ? ` \u2014 ${o.detail}` : "";
873
+ lines.push(`- \`${o.id ?? o.label}\` ${o.label}${rec}${detail}`);
874
+ if (o.command) lines.push(` - Evidence command: \`${o.command}\``);
875
+ }
876
+ if (q.kind === "fact") {
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._`);
878
+ }
879
+ if (q.command) lines.push(` _Recommended check:_ \`${q.command}\``);
880
+ }
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._");
882
+ } else if (v.clarifying.length) {
848
883
  lines.push("", "**To narrow it down, tell me:**");
849
884
  v.clarifying.forEach((q) => lines.push(`- ${q}`));
850
885
  lines.push("", "_Use `ask_followup` to continue this conversation._");
@@ -903,6 +938,30 @@ var diagnosisOutputShape = {
903
938
  steps: z.array(z.string()).describe("The full prescriptive fix ladder, in order."),
904
939
  evidence: z.array(z.string()).describe("Literal lines quoted from the submitted log that support this verdict. An EMPTY array on a high-confidence verdict means the engine could not quote its own log \u2014 weigh it accordingly."),
905
940
  clarifying: z.array(z.string()).describe("Discriminating questions. Answer them with `ask_followup` to narrow the verdict."),
941
+ clarifyingChoices: z.array(z.object({
942
+ id: z.string().optional(),
943
+ kind: z.enum(["fact", "action"]).optional().describe("`fact` = what was observed; `action` = what you could run. Only `action` questions carry a recommended option \u2014 a recommendation on a question of fact would bias the evidence the next verdict is built on."),
944
+ question: z.string().optional(),
945
+ options: z.array(z.object({
946
+ id: z.string().optional(),
947
+ label: z.string().optional(),
948
+ detail: z.string().optional(),
949
+ recommended: z.boolean().optional(),
950
+ command: z.string().optional()
951
+ })).optional(),
952
+ allowOther: z.boolean().optional(),
953
+ command: z.string().optional().describe("The exact command behind the recommended option.")
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."),
906
965
  advisories: z.array(z.string()).describe("Human-readable one-liners, severity-sorted."),
907
966
  advisory: z.array(advisorySchema).describe("Structured cross-cutting advisories, severity-sorted (critical first)."),
908
967
  contradictions: z.array(z.string()).describe("What the engine ruled OUT and why \u2014 the falsification trail."),
@@ -958,6 +1017,19 @@ function fmtDate(ms) {
958
1017
  }
959
1018
 
960
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
+ }
961
1033
  function registerDiagnose(server, ctx) {
962
1034
  server.registerTool(
963
1035
  "diagnose_crash",
@@ -982,17 +1054,29 @@ function registerDiagnose(server, ctx) {
982
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."
983
1055
  ),
984
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."),
985
- 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.")
986
1066
  },
987
1067
  outputSchema: diagnosisOutputShape
988
1068
  },
989
- async ({ logs, file, dir, jobName, runtimeHours, gpuRate, telemetry, codeContext, topologyMap }) => {
1069
+ async ({ logs, file, dir, jobName, runtimeHours, gpuRate, telemetry, codeContext, topologyMap, operatorAnswers, refinementToken }) => {
990
1070
  try {
991
1071
  const resolved = await resolveLogs({ logs, file, dir });
992
1072
  assertEnoughLog(resolved.text);
993
1073
  const prepared = applyPrivacy(condenseLogs(resolved.text), ctx.config.privacy);
1074
+ const preparedRefinementKey = refinementKey(prepared);
994
1075
  const sourceNote = resolved.source === "inline" ? void 0 : `Read \`${resolved.source}\`.`;
995
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
+ }
996
1080
  const result = localDiagnose(prepared);
997
1081
  const localView = viewFromLocal(result, sourceNote);
998
1082
  return toolResult(renderDiagnosis(localView), structuredDiagnosis(localView));
@@ -1013,6 +1097,10 @@ function registerDiagnose(server, ctx) {
1013
1097
  const preparedCodeContext = codeContext ? scrubKeys(codeContext, (v) => scrub(String(v))) : void 0;
1014
1098
  const preparedTopologyMap = topologyMap ? scrubKeys(topologyMap, (ranks) => (Array.isArray(ranks) ? ranks : []).map((r) => scrub(String(r)))) : void 0;
1015
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
+ }
1016
1104
  const payload = await ctx.client.request(endpoint, {
1017
1105
  method: "POST",
1018
1106
  body: {
@@ -1022,13 +1110,23 @@ function registerDiagnose(server, ctx) {
1022
1110
  gpuRate,
1023
1111
  ...preparedTelemetry ? { telemetry: preparedTelemetry } : {},
1024
1112
  ...preparedCodeContext ? { codeContext: preparedCodeContext } : {},
1025
- ...preparedTopologyMap ? { topologyMap: preparedTopologyMap } : {}
1113
+ ...preparedTopologyMap ? { topologyMap: preparedTopologyMap } : {},
1114
+ ...operatorAnswers?.length ? { operatorAnswers } : {}
1026
1115
  },
1116
+ ...operatorAnswers?.length && !ctx.config.apiKey && activeRefinementToken ? {
1117
+ headers: {
1118
+ "X-Denpex-Followup": "1",
1119
+ "X-Denpex-Refinement": activeRefinementToken
1120
+ }
1121
+ } : {},
1027
1122
  // The server budgets up to 290s for the deep/LLM path. Keep a small
1028
1123
  // transport buffer so MCP never discards a result that completed inside
1029
1124
  // the server's supported window.
1030
1125
  timeoutMs: 32e4
1031
1126
  });
1127
+ if (!ctx.config.apiKey && typeof payload.refinementToken === "string" && payload.refinementToken) {
1128
+ rememberRefinement(preparedRefinementKey, payload.refinementToken);
1129
+ }
1032
1130
  const view = viewFromCloud(payload, sourceNote);
1033
1131
  return toolResult(renderDiagnosis(view), structuredDiagnosis(view));
1034
1132
  } catch (e) {
@@ -1726,7 +1824,7 @@ function registerPrompts(server) {
1726
1824
 
1727
1825
  // src/version.ts
1728
1826
  var SERVER_NAME = "denpex";
1729
- var SERVER_VERSION = "0.2.5";
1827
+ var SERVER_VERSION = "0.2.7";
1730
1828
 
1731
1829
  // src/server.ts
1732
1830
  function createServer() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "denpex-mcp",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
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",