@debugg-ai/debugg-ai-mcp 5.0.0 → 6.0.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.
@@ -7,24 +7,119 @@
7
7
  *
8
8
  * Backend contract (camelCase after axiosTransport conversion). The containers
9
9
  * are TOP-LEVEL siblings of `state` on the execution-detail response:
10
- * execution.verdict: { outcome: pass|fail|inconclusive|error|timeout, reason }
10
+ * execution.verdict: { outcome: pass|fail|error|null, reason, skipped? }
11
+ * (outcome null + skipped: true = never attempted a test)
11
12
  * execution.budget: { maxSteps, usedSteps }
12
13
  * execution.evidence: { screenshot, actionTrace }
13
14
  * `verdict` is SINGULAR — distinct from the pre-existing plural `verdicts`
14
15
  * (RunVerdict array) and the raw `outcome` string, neither of which we read.
15
16
  *
16
- * Principle: relay, never invent.
17
- * - `verdict.outcome` is relayed VERBATIM; `success` = (outcome === 'pass').
18
- * - Anything else (fail/inconclusive/error/timeout) → success:false with
19
- * `failureCategory = outcome` — NOT a fabricated 'assertion-mismatch'.
20
- * - A missing / null / unrecognized verdict maps to `inconclusive`, never to
21
- * `fail` and never to the raw execution status.
22
- * - Budget comes from the response, not a client-side constant (the constant
23
- * is only a fallback for pre-contract backends).
17
+ * EVERY verdict a user sees is pass | fail | error (platform-98fv.16,
18
+ * 2026-10-04). `inconclusive`, `unverified`, `unknown` and `abandoned` are gone
19
+ * as user-facing values: they told a user nothing they could act on. They now
20
+ * become `error` carrying the reason "No verdict recorded", which at least says
21
+ * what happened.
22
+ *
23
+ * Principle: relay, never invent — but never relay a non-answer either.
24
+ * - pass/fail are relayed verbatim; everything else collapses to `error`.
25
+ * - nothing derived from the outcome is added beside it (no `success`, no
26
+ * `failureCategory`): a second copy of the verdict is a second thing to
27
+ * disagree with it.
28
+ * - the backend's own `reason` is shown whenever it sends one. We only
29
+ * supply a reason when it did not.
30
+ * - a missing / null / unrecognized verdict on a FINISHED run is `error`,
31
+ * never `fail`, never the raw execution status and never the raw run
32
+ * outcome (`state.outcome` is internal: it can be cancelled / timeout /
33
+ * skipped).
34
+ * - a run that NEVER ATTEMPTED A TEST has no verdict (platform-98fv.16): the
35
+ * backend sends `verdict: { outcome: null, reason, skipped: true }` and the
36
+ * relay is `outcome: null, skipped: true, skipReason: <reason verbatim>`.
37
+ * - a run still going has no verdict yet: `outcome: null`.
38
+ * - `budget` is the backend's container, relayed verbatim or not at all.
39
+ *
40
+ * `timeout` deserves its own note because it is OURS, not the backend's. The
41
+ * MCP synthesises it when its own 10-minute poll deadline expires
42
+ * (services/workflows.ts) — which is a different event from the backend saying
43
+ * a run timed out. Both are `error`, because neither is a verdict about the
44
+ * user's app, but the REASON keeps them apart: ours says we stopped waiting and
45
+ * the run may still be going, theirs says whatever the backend said. Merging
46
+ * them would erase the answer to the first question anyone asks.
47
+ */
48
+ /** The ONLY outcomes a user ever sees. */
49
+ export const USER_FACING_OUTCOMES = ['pass', 'fail', 'error'];
50
+ /**
51
+ * A reason is a FACTUAL RECORD: what we did, what we saw, what happened. No
52
+ * hedges ("may", "likely"), no blame, no advice, and no fixed sentence per
53
+ * category — a canned string reads as an explanation while carrying no
54
+ * information, and the reader cannot tell one occurrence from another.
55
+ *
56
+ * So these are built from the actual values observed, and every number in them
57
+ * is real.
24
58
  */
25
- /** The verdict enum the backend emits (bead sentinal-k8x1f.2). */
26
- export const KNOWN_OUTCOMES = ['pass', 'fail', 'inconclusive', 'error', 'timeout'];
27
- const KNOWN = new Set(KNOWN_OUTCOMES);
59
+ /**
60
+ * THE mapping, exported because more than one field carries an outcome and they
61
+ * must never disagree: the headline `outcome` and the nested
62
+ * `evaluation.outcome` are read by the same person in the same payload. The
63
+ * backend keeps them consistent by deriving both from one verdict
64
+ * (sentinal-sk5sl.1); once the MCP re-maps one of them, it has to re-map the
65
+ * other through the same function or it reintroduces exactly the contradiction
66
+ * that design removed.
67
+ */
68
+ export function toUserFacingOutcome(raw) {
69
+ // Deliberately an allowlist, not a denylist of known non-answers: a value a
70
+ // future backend invents must land on `error` too, rather than leaking
71
+ // through because nobody added it to a list.
72
+ const v = typeof raw === 'string' ? raw.trim() : '';
73
+ return v === 'pass' || v === 'fail' ? v : 'error';
74
+ }
75
+ /**
76
+ * An execution record as the executions tool relays it: the backend object
77
+ * verbatim, except the three fields a user reads as the run's verdict —
78
+ * `outcome`, `verdict.outcome`, `evaluation.outcome` — which go through the
79
+ * same allowlist as check_app_in_browser. A null/absent outcome (a run still
80
+ * going) stays null: "no verdict yet" is not an error. Internal run state
81
+ * (`state.*`, node output) keeps its raw values.
82
+ */
83
+ export function withUserFacingOutcomes(execution) {
84
+ if (!execution || typeof execution !== 'object')
85
+ return execution;
86
+ const mapIfSet = (v) => (typeof v === 'string' && v.trim() !== '' ? toUserFacingOutcome(v) : v);
87
+ const e = { ...execution };
88
+ if ('outcome' in e)
89
+ e.outcome = mapIfSet(e.outcome);
90
+ for (const key of ['verdict', 'evaluation']) {
91
+ if (e[key] && typeof e[key] === 'object' && 'outcome' in e[key]) {
92
+ e[key] = { ...e[key], outcome: mapIfSet(e[key].outcome) };
93
+ }
94
+ }
95
+ return e;
96
+ }
97
+ export function noVerdictReason(rawOutcome) {
98
+ const seen = typeof rawOutcome === 'string' && rawOutcome.trim() !== '' ? rawOutcome.trim() : null;
99
+ return seen
100
+ ? `Backend returned outcome '${seen}' with no reason.`
101
+ : 'Backend returned no outcome and no reason.';
102
+ }
103
+ /**
104
+ * OUR deadline expiring is not the backend reporting a timeout, and the two
105
+ * must stay distinguishable: one means we stopped listening, the other means
106
+ * the run stopped. This states only what we observed — no claim about whether
107
+ * the run is still going, because we do not know.
108
+ */
109
+ export function pollTimeoutReason(facts) {
110
+ const parts = [];
111
+ parts.push(facts.executionUuid
112
+ ? `No result from execution ${facts.executionUuid}`
113
+ : 'No result from the execution');
114
+ if (typeof facts.elapsedMs === 'number') {
115
+ parts.push(`after ${Math.round(facts.elapsedMs / 1000)}s of polling`);
116
+ }
117
+ if (typeof facts.pollCount === 'number') {
118
+ parts.push(`(${facts.pollCount} polls)`);
119
+ }
120
+ const head = parts.join(' ');
121
+ return facts.lastStatus ? `${head}; last status: ${facts.lastStatus}.` : `${head}.`;
122
+ }
28
123
  /** Credential sources that mean "the caller named this account for this run". */
29
124
  const CALLER_SPECIFIED_SOURCES = new Set(['task', 'explicit', 'credential_id']);
30
125
  /**
@@ -59,42 +154,73 @@ export function credentialSubstitutions(logins, namedIdentities) {
59
154
  * Map a workflow execution onto the MCP relay verdict. Never throws.
60
155
  */
61
156
  export function adaptVerdict(execution, opts = {}) {
62
- const state = execution?.state ?? null;
63
157
  // Contract containers live at the TOP LEVEL of the execution response
64
158
  // (siblings of `state`), NOT nested under state.
65
159
  const verdict = execution?.verdict ?? null;
66
160
  const budget = execution?.budget ?? null;
67
161
  const evidence = execution?.evidence ?? null;
68
- // --- Outcome (verbatim relay, inconclusive on anything unrecognized) ---
69
- let outcome;
70
- if (opts.outcomeOverride) {
71
- outcome = opts.outcomeOverride;
162
+ // --- Outcome: pass | fail | error, or null for no verdict ---
163
+ // Only the explicit verdict. NEVER the raw run outcome (state.outcome is the
164
+ // run's internal record — cancelled / timeout / skipped) and never
165
+ // execution.status.
166
+ const rawOutcome = opts.outcomeOverride ?? verdict?.outcome;
167
+ const raw = typeof rawOutcome === 'string' ? rawOutcome.trim() : '';
168
+ const isOurPollTimeout = opts.outcomeOverride === 'timeout';
169
+ const backendReason = typeof verdict?.reason === 'string' && verdict.reason.trim() !== '' ? verdict.reason : null;
170
+ if (!isOurPollTimeout && !raw) {
171
+ // A run that never attempted a test (platform-98fv.16): no verdict, and the
172
+ // backend's recorded reason, verbatim.
173
+ if (verdict?.skipped === true) {
174
+ const skipped = { outcome: null, skipped: true };
175
+ if (backendReason)
176
+ skipped.skipReason = backendReason;
177
+ if (budget && typeof budget === 'object')
178
+ skipped.budget = budget;
179
+ return attachEvidence(skipped, evidence);
180
+ }
181
+ // Still going: no verdict YET is not an error.
182
+ if (!TERMINAL_STATUSES.has(String(execution?.status ?? ''))) {
183
+ const pending = { outcome: null };
184
+ if (budget && typeof budget === 'object')
185
+ pending.budget = budget;
186
+ return attachEvidence(pending, evidence);
187
+ }
188
+ }
189
+ // error covers: the backend's own 'error'/'timeout', every retired non-answer
190
+ // (inconclusive/unverified/unknown/abandoned), anything a newer backend
191
+ // invents, and a verdict that never arrived.
192
+ const outcome = toUserFacingOutcome(raw);
193
+ const relay = { outcome };
194
+ if (budget && typeof budget === 'object')
195
+ relay.budget = budget;
196
+ // Reason precedence, and the order is the whole point:
197
+ // 1. the backend's own words, relayed VERBATIM and never rephrased;
198
+ // 2. our poll deadline, stated as what we observed — only when WE forced
199
+ // the timeout, so a backend-reported timeout keeps its own reason;
200
+ // 3. a factual record of what arrived, when nothing explained itself.
201
+ // OUR deadline comes first, deliberately. If we never saw a terminal verdict,
202
+ // any reason we are holding came from a mid-flight poll — it describes a run
203
+ // still in progress, not its result. Relaying it next to outcome='error'
204
+ // would pair someone else's "looks good" with our failure.
205
+ if (isOurPollTimeout) {
206
+ relay.reason = pollTimeoutReason({
207
+ executionUuid: execution?.uuid,
208
+ lastStatus: execution?.status,
209
+ ...(opts.pollTimeout ?? {}),
210
+ });
72
211
  }
73
- else {
74
- // Prefer the explicit verdict; fall back to the legacy per-run outcome
75
- // field; NEVER fall back to execution.status. Unknown values → inconclusive.
76
- const raw = verdict?.outcome ?? state?.outcome;
77
- outcome = typeof raw === 'string' && KNOWN.has(raw) ? raw : 'inconclusive';
212
+ else if (backendReason) {
213
+ relay.reason = backendReason;
78
214
  }
79
- const success = outcome === 'pass';
80
- // --- Budget (from the response; constant is a fallback only) ---
81
- const fallbackBudget = opts.fallbackBudget ?? 0;
82
- const stepsBudget = typeof budget?.maxSteps === 'number' ? budget.maxSteps : fallbackBudget;
83
- const stepsTaken = typeof budget?.usedSteps === 'number'
84
- ? budget.usedSteps
85
- : (typeof state?.stepsTaken === 'number' ? state.stepsTaken : 0);
86
- const stepsRemaining = Math.max(0, stepsBudget - stepsTaken);
87
- const relay = {
88
- outcome,
89
- success,
90
- stepsBudget,
91
- stepsTaken,
92
- stepsRemaining,
93
- };
94
- if (!success)
95
- relay.failureCategory = outcome;
96
- if (typeof verdict?.reason === 'string' && verdict.reason)
97
- relay.reason = verdict.reason;
215
+ else if (outcome === 'error') {
216
+ relay.reason = noVerdictReason(raw);
217
+ }
218
+ return attachEvidence(relay, evidence);
219
+ }
220
+ /** The run lifecycle states after which no verdict will arrive. */
221
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
222
+ /** The backend's evidence container onto the relay, verbatim. */
223
+ function attachEvidence(relay, evidence) {
98
224
  if (typeof evidence?.screenshot === 'string' && evidence.screenshot)
99
225
  relay.screenshot = evidence.screenshot;
100
226
  if (Array.isArray(evidence?.actionTrace))
@@ -93,7 +93,10 @@ export const createWorkflowsService = (tx) => {
93
93
  status: e.status,
94
94
  mode: e.mode,
95
95
  source: e.source,
96
+ // The run verdict (pass|fail|error); null in flight AND for a run that
97
+ // never attempted a test — then skipReason (backend `skip_reason`) is set.
96
98
  outcome: e.outcome ?? null,
99
+ ...(typeof e.skipReason === 'string' && e.skipReason ? { skipReason: e.skipReason } : {}),
97
100
  startedAt: e.startedAt,
98
101
  completedAt: e.completedAt,
99
102
  durationMs: e.durationMs,
@@ -171,7 +174,13 @@ export const createWorkflowsService = (tx) => {
171
174
  finalIntervalMs: intervalMs,
172
175
  timedOut: true,
173
176
  });
174
- return { ...lastExecution, timedOut: true };
177
+ // Carry the facts the deadline path observed, so the reason the user
178
+ // sees contains real numbers rather than a canned sentence.
179
+ return {
180
+ ...lastExecution,
181
+ timedOut: true,
182
+ pollTimeout: { elapsedMs: Date.now() - pollStart, pollCount },
183
+ };
175
184
  }
176
185
  throw new Error(`Execution ${executionUuid} timed out after ${EXECUTION_TIMEOUT_MS / 1000}s`);
177
186
  }
@@ -4,17 +4,17 @@
4
4
  * The MCP ships this field before every backend accepts it (sentinal-oj7dp.23).
5
5
  * A backend that does not know the field ignores it and still answers 200, so a
6
6
  * create/update "succeeds" whether or not the hosts were saved. The only proof
7
- * is the response echoing them back — so when it doesn't, say so loudly instead
8
- * of letting the caller believe cross-domain SSO is now configured.
7
+ * is the response echoing them back — so when it doesn't, the response carries
8
+ * the two facts side by side: what was sent, what came back. No prose: the
9
+ * comparison is the whole message.
9
10
  */
10
11
  const norm = (hosts) => new Set(hosts.map((h) => h.trim().toLowerCase()));
11
12
  /**
12
- * Compare the requested hosts with the backend's echo. Returns a warning when
13
- * they differ (as sets, case-insensitively — the backend normalizes), or
14
- * undefined when the echo confirms them. `op` shapes the advice: a create has
15
- * already created the environment, so a retry of create would duplicate it.
13
+ * Compare the requested hosts with the backend's echo. Returns the mismatch
14
+ * when they differ (as sets, case-insensitively — the backend normalizes), or
15
+ * undefined when the echo confirms them.
16
16
  */
17
- export function checkAuthorizedCredentialHostsEcho(requested, returned, op) {
17
+ export function checkAuthorizedCredentialHostsEcho(requested, returned) {
18
18
  const echoed = Array.isArray(returned) ? returned : null;
19
19
  if (echoed) {
20
20
  const want = norm(requested);
@@ -22,17 +22,5 @@ export function checkAuthorizedCredentialHostsEcho(requested, returned, op) {
22
22
  if (want.size === got.size && [...want].every((h) => got.has(h)))
23
23
  return undefined;
24
24
  }
25
- const hosts = requested.length > 0 ? requested.join(', ') : '(an empty list)';
26
- const why = echoed
27
- ? `The backend did not persist authorizedCredentialHosts as requested: it holds [${echoed.join(', ')}], not [${requested.join(', ')}].`
28
- : 'The backend did not persist authorizedCredentialHosts: it is not yet supported on this DebuggAI server ' +
29
- '(the response did not echo the hosts back).';
30
- const applied = op === 'create'
31
- ? 'The environment itself WAS created, with any credentials listed here — do not re-run create; ' +
32
- 'set the hosts later with environment {action:"update"}.'
33
- : 'Everything else in this update was applied.';
34
- const consequence = requested.length > 0
35
- ? ` Until the hosts are saved, check_app_in_browser will still refuse to enter credentials on ${hosts} (offscope_host).`
36
- : '';
37
- return { requested, returned: echoed, message: `${why} ${applied}${consequence}` };
25
+ return { requested, returned: echoed };
38
26
  }
@@ -69,6 +69,5 @@ export async function ensureConfirmed(action, label, input, ctx) {
69
69
  }
70
70
  if (input.confirm === true)
71
71
  return null;
72
- return refusal('confirmation_required', `Refusing to ${verb.toLowerCase()} ${label} without confirmation. `
73
- + 'Pass confirm:true, or use an elicitation-capable client.');
72
+ return refusal('confirmation_required', `Refusing to ${verb.toLowerCase()} ${label} without confirm:true.`);
74
73
  }
@@ -18,11 +18,10 @@ export function toMCPError(error, context) {
18
18
  stack: error.stack,
19
19
  context
20
20
  });
21
- return new MCPError(MCPErrorCode.INTERNAL_ERROR, error.message, {
22
- originalError: error.name,
23
- context,
24
- stack: error.stack
25
- });
21
+ return new MCPError(MCPErrorCode.INTERNAL_ERROR, error.message,
22
+ // The stack is logged above; it is MCP internals, not something the
23
+ // caller's response should carry.
24
+ { originalError: error.name, context });
26
25
  }
27
26
  const errorMessage = String(error);
28
27
  logger.error('Converting unknown error to MCP error', { error: errorMessage, context });
@@ -92,7 +92,6 @@ export function artifactResourceLinks(source) {
92
92
  out.push(resourceLinkBlock(value, name, {
93
93
  mimeType: MIME_BY_EXT[ext],
94
94
  title: titleize(key),
95
- description: 'Execution artifact (presigned URL — open or fetch on demand).',
96
95
  }));
97
96
  }
98
97
  return out;
@@ -19,7 +19,7 @@ export function resolveByName(name, candidates, kind) {
19
19
  if (matches.length > 1) {
20
20
  return {
21
21
  error: 'AmbiguousMatch',
22
- message: `Multiple ${kind.toLowerCase().replace('testsuite', 'test suite')}s match "${name}". Pass the uuid directly.`,
22
+ message: `Multiple ${kind.toLowerCase().replace('testsuite', 'test suite')}s match "${name}".`,
23
23
  candidates: matches.map(m => ({ uuid: m.uuid, name: m.name })),
24
24
  };
25
25
  }
@@ -134,6 +134,39 @@ export function retargetTunnelUrl(tunnelOrigin, requestedUrl) {
134
134
  return tunnelOrigin;
135
135
  }
136
136
  }
137
+ /**
138
+ * Rewrite every localhost / 127.0.0.1 URL inside FREE TEXT onto the tunnel.
139
+ *
140
+ * The goal text is read by a browser running in OUR cloud, not on the user's
141
+ * machine, so "go to http://localhost:3017" tells it to dial its own loopback.
142
+ * It gets connection refused and the run is recorded as the app failing, while
143
+ * the tunnel was working the whole time (15 of 431 judged prod runs in 30 days,
144
+ * platform-98fv.16).
145
+ *
146
+ * Deliberately narrow:
147
+ * - only a real origin match (scheme + host + optional port) is touched, so
148
+ * a word like "localhostname" survives;
149
+ * - path, query and fragment are preserved, because the goal usually depends
150
+ * on them;
151
+ * - with no tunnel URL, the text is returned UNCHANGED. Inventing a
152
+ * destination would be worse than leaving a wrong one visible.
153
+ */
154
+ export function rewriteLocalhostInText(text, tunnelUrl) {
155
+ if (!text || !tunnelUrl)
156
+ return text;
157
+ let origin;
158
+ try {
159
+ const u = new URL(tunnelUrl);
160
+ origin = `${u.protocol}//${u.host}`;
161
+ }
162
+ catch {
163
+ return text;
164
+ }
165
+ // scheme://(localhost|127.0.0.1)[:port] — the trailing boundary stops
166
+ // "localhostname" matching, while still allowing a path/query/fragment.
167
+ const LOCAL_ORIGIN = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(?=[/?#\s]|$)/gi;
168
+ return text.replace(LOCAL_ORIGIN, origin);
169
+ }
137
170
  /**
138
171
  * Generate a tunneled URL for a localhost URL
139
172
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugg-ai/debugg-ai-mcp",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.",
5
5
  "type": "module",
6
6
  "caddy": "2.11.3",