Skip to content

Commit e9993dc

Browse files
feat(telemetry): docker isolation flag, CLI source + docker failure codes (schema v5) (#702)
* feat(telemetry): emit docker isolation flag, CLI source + docker failure codes (schema v5) Make the #696 (docker CLI not on PATH) and image/interpreter-mismatch failure modes visible in the telemetry dashboard instead of guessing. Feature flags (schema bumped 4 -> 5, additive / forward-compatible): - docker_isolation_enabled (bool) from cfg.DockerIsolation in BuildFeatureFlagSnapshot — distinguishes "isolation on, 0 matching servers" from "isolation off". - docker_cli_source (enum path|bundled|login_shell|absent) — the direct #696 fleet signal. New shellwrap.ResolveDockerSource reports which resolution branch found docker (shares the docker-path cache); Runtime.GetDockerCLISource mirrors IsDockerAvailable. Coarse enum only — never the path string. Diagnostics (flow into error_code_counts_24h, MCPX-prefix gated/PII-safe): - MCPX_DOCKER_CLI_NOT_FOUND — isolation requested but docker binary unresolved. - MCPX_DOCKER_EXEC_NOT_FOUND — in-container interpreter missing (e.g. uvx absent in python:3.11); previously misclassified as MCPX_STDIO_SPAWN_ENOENT. - MCPX_DOCKER_OCI_RUNTIME — OCI runtime / exec-format failures. ClassifierHints gains DockerIsolated; the supervisor threads it from a side-effect-free mirror of IsolationManager.ShouldIsolate so containerized ENOENT routes to DOCKER codes. Catalog entries added for all three. Privacy: all new fields are coarse enums/bools; docker_cli_source canary test asserts only the 4 enum values ever reach the wire and ScanForPII stays green. Docs/contracts updated (docs/features/telemetry.md + v2/v3 contract notes). DASHBOARD HAND-OFF (telemetry.mcpproxy.app, separate service): add panels for feature_flags.docker_isolation_enabled, the 4-way docker_cli_source breakdown, and the 3 new MCPX_DOCKER_* rows in the error_code_counts_24h panel. Related #696 Co-Authored-By: Paperclip <noreply@paperclip.ing> * test(httpapi): add GetDockerCLISource to fakeRuntimeStats for schema v5 The RuntimeStats interface gained GetDockerCLISource() (MCP-2745); the httpapi telemetry payload test's fake stub must implement it or the package fails to typecheck under the full-repo CI lint. Related #696 Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(shellwrap): set docker source on stat-probe cache upgrade (Codex P2) ResolveDockerPath's MCP-2744 stat-probe override (upgrading a live cached negative to a permanent success when the well-known-path probe finds docker) updated dockerPath/dockerPathErr but not dockerPathSource. A stale 'absent' from the prior failed resolution then leaked into docker_cli_source telemetry (schema v5) even though docker WAS resolved via the bundled path. Set dockerPathSource = DockerSourceBundled in that branch. Regression test asserts a cached negative upgraded via the stat probe reports 'bundled', not 'absent' (fails without the one-line fix). Related #696 Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(diagnostics): classify zsh 'command not found: docker' as CLI-not-found (Codex P2 r3) zsh's login-shell wording is reversed — 'zsh:1: command not found: docker' (name AFTER the colon), not bash/sh's 'docker: command not found'. The CLI-not- found case only matched the bash shape, so the common macOS #696 scenario fell through to the generic 'command not found' branch and recorded MCPX_DOCKER_EXEC_NOT_FOUND, conflating host-CLI-missing (#696) with in-container exec ENOENT. Match 'command not found: docker' in both the isolation-spawn helper and the unhinted classifyDocker fallback. Added a classifier table case for the zsh wording (verified red→green: routed to EXEC_NOT_FOUND without the fix, CLI_NOT_FOUND with it). Related #696 Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(shellwrap): unify ResolveDockerSource with ResolveDockerPath cache path (Codex P2 r4) ResolveDockerSource honored a live cached negative by returning 'absent' WITHOUT running the well-known-path stat probe that ResolveDockerPath uses during the negative-TTL window — so docker_cli_source reported 'absent' while ResolveDockerPath returned the bundled path (schema-v5 telemetry mismatch). Root-cause the recurring divergence (this is the 2nd source-tracking drift between the two functions) by extracting a single shared resolveDockerPathLocked helper that is the only writer of the cache + dockerPathSource. ResolveDockerPath and ResolveDockerSource now drive the identical cache path, including the MCP-2744 override, so they cannot disagree on the same cache state. Regression test asserts ResolveDockerSource reports 'bundled' (not 'absent') when a well-known docker appears during the negative-TTL window — verified red->green against the shared-resolver refactor. Related #696 Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(diagnostics): don't label non-docker 'exec format error' as OCI (Codex P2 r5) The unhinted classifyDocker matched a BARE 'exec format error' as MCPX_DOCKER_OCI_RUNTIME, but a non-docker wrong-architecture host stdio binary emits the same string (ENOEXEC) — it would be mislabeled a Docker/OCI failure. - classifyDocker OCI case now requires real OCI/runc context ('oci runtime' / 'runc'); bare 'exec format error' no longer matches there. - classifyStdio routes a bare 'exec format error' (and typed syscall.ENOEXEC) to a new STDIO code MCPX_STDIO_SPAWN_EXEC_FORMAT, guarded against docker OCI context so a real containerized exec-format still falls through to OCI. - The docker-isolated path keeps classifying bare 'exec format error' as OCI via the hinted classifyDockerIsolatedSpawn. - New catalog entry for MCPX_STDIO_SPAWN_EXEC_FORMAT. Classifier table cases: bare exec-format WITH hint -> OCI; bare exec-format WITHOUT hint -> STDIO (verified red MCPX_DOCKER_OCI_RUNTIME -> green MCPX_STDIO_SPAWN_EXEC_FORMAT); 'oci runtime' context without hint -> OCI. Related #696 Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 5bac648 commit e9993dc

20 files changed

Lines changed: 662 additions & 36 deletions

File tree

docs/features/telemetry.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ MCPProxy collects anonymous usage statistics to help improve the product. This p
44

55
## What is collected
66

7-
MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 3** (`schema_version: 3` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize.
7+
MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 5** (`schema_version: 5` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize.
88

99
| Field | Example | Purpose |
1010
|-------|---------|---------|
@@ -22,9 +22,15 @@ MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying
2222
| `feature_flags.docker_available` | `true` | Fraction of installs with a reachable Docker daemon (schema v3) |
2323
| `server_protocol_counts` | `{"stdio":3,"http":2,"sse":0,"streamable_http":1,"auto":0}` | Ratio of remote-HTTP vs local-stdio upstreams (schema v3) |
2424
| `server_docker_isolated_count` | `2` | How many configured servers the runtime actually wraps in Docker isolation (schema v3) |
25+
| `feature_flags.docker_isolation_enabled` | `true` | Whether global Docker isolation is turned on (schema v5). Lets us tell "isolation on, 0 matching servers" apart from "isolation off" |
26+
| `feature_flags.docker_cli_source` | `bundled` | How the `docker` CLI was located — fixed enum `path` / `bundled` / `login_shell` / `absent` (schema v5). The direct signal for "Docker installed but not on the spawn PATH" (issue #696). **Never** the path string itself |
2527

2628
The `server_protocol_counts` map uses a **fixed enum of keys** (`stdio`, `http`, `sse`, `streamable_http`, `auto`) — server names and URLs are never included. Unknown or misconfigured protocol values are bucketed into `auto`.
2729

30+
The `docker_cli_source` field is likewise a **fixed enum** (`path`, `bundled`, `login_shell`, `absent`); the resolved path is never transmitted.
31+
32+
Docker isolation failures surface in `error_code_counts_24h` via three stable diagnostic codes (schema v5): `MCPX_DOCKER_CLI_NOT_FOUND` (isolation requested but the `docker` binary is unresolved — issue #696), `MCPX_DOCKER_EXEC_NOT_FOUND` (the image lacks the interpreter the server needs, e.g. `uvx` missing in `python:3.11`), and `MCPX_DOCKER_OCI_RUNTIME` (OCI runtime / architecture-mismatch failures).
33+
2834
## What is NOT collected
2935

3036
The following is **never** collected:

internal/diagnostics/classifier.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ func classifyDocker(err error, _ ClassifierHints) Code {
119119
strings.Contains(msg, "docker") && strings.Contains(msg, "image") && strings.Contains(msg, "pull") && strings.Contains(msg, "fail"),
120120
strings.Contains(msg, "manifest unknown"):
121121
return DockerImagePullFailed
122+
// docker CLI unresolved (#696). These shapes are unambiguous about the
123+
// docker BINARY being missing, so they classify even without the
124+
// DockerIsolated hint (e.g. shellwrap's resolution-failure error).
125+
case strings.Contains(msg, "docker not found in path"),
126+
strings.Contains(msg, "docker not found in login shell"),
127+
strings.Contains(msg, "docker: command not found"),
128+
strings.Contains(msg, "command not found: docker"), // zsh: "zsh:1: command not found: docker"
129+
strings.Contains(msg, "docker: not found"),
130+
strings.Contains(msg, `"docker": executable file not found`):
131+
return DockerCLINotFound
132+
// OCI runtime failures from `docker run`. NOTE: a BARE "exec format error"
133+
// is intentionally NOT matched here — a non-docker, wrong-architecture host
134+
// stdio binary emits the same string and must stay STDIO-classified. The
135+
// docker-isolated path routes bare "exec format error" via the hinted
136+
// classifyDockerIsolatedSpawn; here we require real OCI/runc context.
137+
case strings.Contains(msg, "oci runtime"),
138+
strings.Contains(msg, "runc"):
139+
return DockerOCIRuntime
122140
}
123141
return ""
124142
}
@@ -155,8 +173,66 @@ func classifyQuarantine(err error, _ ClassifierHints) Code {
155173
return ""
156174
}
157175

176+
// classifyDockerIsolatedSpawn maps a spawn/exec failure on a Docker-isolated
177+
// server to a specific DOCKER code. Returns "" when the error is not a
178+
// recognised docker-isolation failure (caller falls through to generic stdio
179+
// handling).
180+
//
181+
// Case order is load-bearing:
182+
// 1. The docker BINARY itself is missing (#696) — must win even though its
183+
// message also contains "command not found" / "executable file not found".
184+
// 2. The in-container interpreter is missing — real docker output nests this
185+
// inside an "OCI runtime create failed: … exec: \"x\": executable file not
186+
// found" string, so it must be checked BEFORE the generic OCI case below.
187+
// 3. Any other OCI runtime failure (exec format error / runc).
188+
func classifyDockerIsolatedSpawn(err error) Code {
189+
// Host couldn't even start the docker binary (direct exec path).
190+
var execErr *exec.Error
191+
if errors.As(err, &execErr) && errors.Is(execErr.Err, syscall.ENOENT) &&
192+
strings.Contains(strings.ToLower(execErr.Name), "docker") {
193+
return DockerCLINotFound
194+
}
195+
196+
msg := strings.ToLower(err.Error())
197+
switch {
198+
// (1) docker binary unresolved: shellwrap resolution failure, or the shell
199+
// / Go exec layer reporting `docker` itself missing. Cover both shell
200+
// wordings: bash/sh `docker: command not found` AND zsh's reversed
201+
// `zsh:1: command not found: docker` (the common macOS login-shell shape) —
202+
// the latter must beat the generic "command not found" → EXEC case below.
203+
case strings.Contains(msg, `"docker": executable file not found`),
204+
strings.Contains(msg, "docker: command not found"),
205+
strings.Contains(msg, "command not found: docker"),
206+
strings.Contains(msg, "docker: not found"),
207+
strings.Contains(msg, "docker not found in path"),
208+
strings.Contains(msg, "docker not found in login shell"):
209+
return DockerCLINotFound
210+
// (2) in-container interpreter missing (image lacks uvx/node/python/…).
211+
case strings.Contains(msg, "executable file not found"),
212+
strings.Contains(msg, "no such file or directory"),
213+
strings.Contains(msg, "command not found"):
214+
return DockerExecNotFound
215+
// (3) other OCI runtime failures (arch mismatch, runc start failure).
216+
case strings.Contains(msg, "oci runtime"),
217+
strings.Contains(msg, "exec format error"),
218+
strings.Contains(msg, "runc"):
219+
return DockerOCIRuntime
220+
}
221+
return ""
222+
}
223+
158224
// classifyStdio handles os/exec spawn errors and handshake failures.
159225
func classifyStdio(err error, hints ClassifierHints) Code {
226+
// Docker-isolated servers run `docker run …` over the stdio transport, so
227+
// ENOENT-class failures here are docker-specific (#696 CLI missing, or an
228+
// image/interpreter mismatch) rather than a plain host-binary miss. Resolve
229+
// those to DOCKER codes before the generic stdio matching below.
230+
if hints.DockerIsolated {
231+
if c := classifyDockerIsolatedSpawn(err); c != "" {
232+
return c
233+
}
234+
}
235+
160236
var execErr *exec.Error
161237
if errors.As(err, &execErr) {
162238
// exec.Error wraps os.PathError which wraps syscall.Errno; ENOENT/EACCES
@@ -167,6 +243,9 @@ func classifyStdio(err error, hints ClassifierHints) Code {
167243
if errors.Is(execErr.Err, syscall.EACCES) {
168244
return STDIOSpawnEACCES
169245
}
246+
if errors.Is(execErr.Err, syscall.ENOEXEC) {
247+
return STDIOSpawnExecFormat
248+
}
170249
}
171250

172251
// exec.ExitError — process started but exited non-zero during handshake.
@@ -193,6 +272,13 @@ func classifyStdio(err error, hints ClassifierHints) Code {
193272
msg := err.Error()
194273
lmsg := strings.ToLower(msg)
195274
switch {
275+
// Wrong-arch / non-executable host binary (ENOEXEC). Guarded against
276+
// docker OCI context ("oci runtime"/"runc") so a real containerized
277+
// exec-format failure still falls through to classifyDocker → OCI; a
278+
// BARE "exec format error" is a host stdio problem, not a Docker one.
279+
case strings.Contains(lmsg, "exec format error") &&
280+
!strings.Contains(lmsg, "oci runtime") && !strings.Contains(lmsg, "runc"):
281+
return STDIOSpawnExecFormat
196282
case strings.Contains(lmsg, "no such file or directory"),
197283
strings.Contains(lmsg, "executable file not found"),
198284
strings.Contains(lmsg, "command not found"):

internal/diagnostics/classifier_domains_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,99 @@ func TestClassify_Docker_SnapAppArmor(t *testing.T) {
7878
}
7979
}
8080

81+
// TestClassify_Docker_IsolationSpawn exercises the #696 / image-mismatch
82+
// routing: when the docker-isolation hint is set, ENOENT-class failures on the
83+
// stdio transport must resolve to DOCKER codes rather than a plain
84+
// MCPX_STDIO_SPAWN_ENOENT, so the telemetry dashboard sees the real cause.
85+
func TestClassify_Docker_IsolationSpawn(t *testing.T) {
86+
cases := []struct {
87+
name string
88+
err error
89+
hint ClassifierHints
90+
want Code
91+
}{
92+
{
93+
// #696: docker CLI not on the spawn PATH; the login-shell wrap
94+
// reports `docker: command not found`.
95+
name: "cli_not_found_shell",
96+
err: errors.New("stdio transport (command=\"/bin/zsh\", docker_isolation=true): recent stderr: docker: command not found"),
97+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
98+
want: DockerCLINotFound,
99+
},
100+
{
101+
// #696 via zsh login shell: the common macOS shape is the REVERSED
102+
// wording `zsh:1: command not found: docker` (name after the colon),
103+
// which must still classify as CLI-not-found, not in-container EXEC.
104+
name: "cli_not_found_zsh_reversed",
105+
err: errors.New("stdio transport (docker_isolation=true): recent stderr: zsh:1: command not found: docker"),
106+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
107+
want: DockerCLINotFound,
108+
},
109+
{
110+
// shellwrap resolution failure surfaces this even without the hint.
111+
name: "cli_not_found_resolve",
112+
err: errors.New("docker not found in PATH or well-known locations"),
113+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
114+
want: DockerCLINotFound,
115+
},
116+
{
117+
// In-container interpreter missing (e.g. uvx absent in python:3.11).
118+
name: "exec_not_found",
119+
err: errors.New("docker: Error response from daemon: failed to create task: OCI runtime create failed: runc create failed: exec: \"uvx\": executable file not found in $PATH: unknown"),
120+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
121+
want: DockerExecNotFound,
122+
},
123+
{
124+
// OCI runtime / arch mismatch with no interpreter-missing detail.
125+
name: "oci_runtime",
126+
err: errors.New("docker: Error response from daemon: failed to create shim task: OCI runtime create failed: exec format error: unknown"),
127+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
128+
want: DockerOCIRuntime,
129+
},
130+
{
131+
// Bare "exec format error" WITH the isolation hint → OCI (wrong-arch
132+
// image under docker isolation).
133+
name: "bare_exec_format_isolated",
134+
err: errors.New("stdio transport (docker_isolation=true): recent stderr: exec format error"),
135+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
136+
want: DockerOCIRuntime,
137+
},
138+
{
139+
// Bare "exec format error" WITHOUT the isolation hint must stay
140+
// STDIO-classified (a non-docker wrong-arch host binary), NOT a
141+
// Docker code. Codex round-5 regression.
142+
name: "bare_exec_format_no_hint_stays_stdio",
143+
err: errors.New("failed to spawn stdio server: recent stderr: exec format error"),
144+
hint: ClassifierHints{Transport: "stdio"},
145+
want: STDIOSpawnExecFormat,
146+
},
147+
{
148+
// A real docker OCI error that lacks the hint but carries "oci
149+
// runtime" context still classifies as OCI (not STDIO), via the
150+
// classifyDocker fallback.
151+
name: "oci_context_no_hint",
152+
err: errors.New("oci runtime create failed: exec format error"),
153+
hint: ClassifierHints{Transport: "stdio"},
154+
want: DockerOCIRuntime,
155+
},
156+
{
157+
// Same ENOENT string WITHOUT the isolation hint stays a plain stdio
158+
// spawn failure — no false DOCKER attribution for host stdio servers.
159+
name: "non_containerized_enoent",
160+
err: errors.New("failed to spawn: executable file not found in $PATH"),
161+
hint: ClassifierHints{Transport: "stdio"},
162+
want: STDIOSpawnENOENT,
163+
},
164+
}
165+
for _, tc := range cases {
166+
t.Run(tc.name, func(t *testing.T) {
167+
if got := Classify(tc.err, tc.hint); got != tc.want {
168+
t.Errorf("Classify(%q) = %q, want %q", tc.err, got, tc.want)
169+
}
170+
})
171+
}
172+
}
173+
81174
// --- CONFIG -----------------------------------------------------------------
82175

83176
func TestClassify_Config_ParseError(t *testing.T) {

internal/diagnostics/codes.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ package diagnostics
88

99
// STDIO domain — stdio-transport MCP server failures.
1010
const (
11-
STDIOSpawnENOENT Code = "MCPX_STDIO_SPAWN_ENOENT"
12-
STDIOSpawnEACCES Code = "MCPX_STDIO_SPAWN_EACCES"
11+
STDIOSpawnENOENT Code = "MCPX_STDIO_SPAWN_ENOENT"
12+
STDIOSpawnEACCES Code = "MCPX_STDIO_SPAWN_EACCES"
13+
// STDIOSpawnExecFormat: the stdio binary exists but is the wrong CPU
14+
// architecture / not an executable format (ENOEXEC — "exec format error").
15+
// Distinct from a Docker/OCI exec-format failure, which is
16+
// MCPX_DOCKER_OCI_RUNTIME under the docker-isolation hint.
17+
STDIOSpawnExecFormat Code = "MCPX_STDIO_SPAWN_EXEC_FORMAT"
1318
STDIOExitNonzero Code = "MCPX_STDIO_EXIT_NONZERO"
1419
STDIOExitBeforeInitialize Code = "MCPX_STDIO_EXIT_BEFORE_INITIALIZE"
1520
STDIOHandshakeTimeout Code = "MCPX_STDIO_HANDSHAKE_TIMEOUT"
@@ -51,6 +56,17 @@ const (
5156
DockerImagePullFailed Code = "MCPX_DOCKER_IMAGE_PULL_FAILED"
5257
DockerNoPermission Code = "MCPX_DOCKER_NO_PERMISSION"
5358
DockerSnapAppArmor Code = "MCPX_DOCKER_SNAP_APPARMOR"
59+
// DockerCLINotFound: isolation was requested but the `docker` binary could
60+
// not be resolved on the spawn PATH (issue #696 — Docker Desktop installed
61+
// without the admin-gated CLI shim, or a LaunchAgent's minimal PATH).
62+
DockerCLINotFound Code = "MCPX_DOCKER_CLI_NOT_FOUND"
63+
// DockerExecNotFound: the container started but its entrypoint interpreter
64+
// is missing from the image (e.g. `uvx` absent in `python:3.11`). Distinct
65+
// from a HOST stdio ENOENT, which is MCPX_STDIO_SPAWN_ENOENT.
66+
DockerExecNotFound Code = "MCPX_DOCKER_EXEC_NOT_FOUND"
67+
// DockerOCIRuntime: the OCI runtime (runc) failed to start the container —
68+
// e.g. an `exec format error` (image/host architecture mismatch).
69+
DockerOCIRuntime Code = "MCPX_DOCKER_OCI_RUNTIME"
5470
)
5571

5672
// CONFIG domain — configuration parsing and validation failures.

internal/diagnostics/registry.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ func seedSTDIO() {
5151
},
5252
DocsURL: docsURL(STDIOSpawnEACCES),
5353
})
54+
register(CatalogEntry{
55+
Code: STDIOSpawnExecFormat,
56+
Severity: SeverityError,
57+
UserMessage: "The configured command is the wrong CPU architecture or not an executable (exec format error). Install a build that matches this machine.",
58+
FixSteps: []FixStep{
59+
{Type: FixStepCommand, Label: "Check the binary's architecture", Command: "file <command-path>"},
60+
{Type: FixStepLink, Label: "Install a matching build", URL: docsURL(STDIOSpawnExecFormat)},
61+
},
62+
DocsURL: docsURL(STDIOSpawnExecFormat),
63+
})
5464
register(CatalogEntry{
5565
Code: STDIOExitNonzero,
5666
Severity: SeverityError,
@@ -292,6 +302,34 @@ func seedDOCKER() {
292302
},
293303
DocsURL: docsURL(DockerSnapAppArmor),
294304
})
305+
register(CatalogEntry{
306+
Code: DockerCLINotFound,
307+
Severity: SeverityError,
308+
UserMessage: "Docker isolation is enabled but the `docker` command could not be found. Install Docker, or add its CLI to your PATH.",
309+
FixSteps: []FixStep{
310+
{Type: FixStepCommand, Label: "Check docker is on PATH", Command: "docker --version"},
311+
{Type: FixStepLink, Label: "Install Docker / enable the CLI", URL: docsURL(DockerCLINotFound)},
312+
},
313+
DocsURL: docsURL(DockerCLINotFound),
314+
})
315+
register(CatalogEntry{
316+
Code: DockerExecNotFound,
317+
Severity: SeverityError,
318+
UserMessage: "The Docker image is missing the interpreter this server needs (e.g. the image has no `uvx`/`node`). Pick an image that includes it.",
319+
FixSteps: []FixStep{
320+
{Type: FixStepLink, Label: "Choosing a Docker isolation image", URL: docsURL(DockerExecNotFound)},
321+
},
322+
DocsURL: docsURL(DockerExecNotFound),
323+
})
324+
register(CatalogEntry{
325+
Code: DockerOCIRuntime,
326+
Severity: SeverityError,
327+
UserMessage: "The Docker container failed to start (OCI runtime error). This is often an image/CPU architecture mismatch.",
328+
FixSteps: []FixStep{
329+
{Type: FixStepLink, Label: "Troubleshooting OCI runtime errors", URL: docsURL(DockerOCIRuntime)},
330+
},
331+
DocsURL: docsURL(DockerOCIRuntime),
332+
})
295333
}
296334

297335
// --- CONFIG --------------------------------------------------------------

internal/diagnostics/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ type DiagnosticError struct {
6767
type ClassifierHints struct {
6868
Transport string // "stdio", "http", "sse", "docker", etc.
6969
ServerID string
70+
// DockerIsolated is true when the failing server is launched through Docker
71+
// isolation (`docker run …` over the stdio transport). It lets the
72+
// classifier route ENOENT-class spawn failures to DOCKER codes (CLI missing
73+
// per #696, in-container interpreter missing) instead of a generic
74+
// MCPX_STDIO_SPAWN_ENOENT. See classifyDockerIsolatedSpawn.
75+
DockerIsolated bool
7076
}
7177

7278
// FixRequest is the input to a registered fixer.

internal/httpapi/telemetry_payload_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func (fakeRuntimeStats) GetRoutingMode() string { return "retrieve_to
3636
func (fakeRuntimeStats) IsQuarantineEnabled() bool { return true }
3737
func (fakeRuntimeStats) IsDockerAvailable() bool { return false }
3838
func (fakeRuntimeStats) GetDockerIsolatedServerCount() int { return 0 }
39+
func (fakeRuntimeStats) GetDockerCLISource() string { return "absent" }
3940

4041
func TestHandleGetTelemetryPayload_OK(t *testing.T) {
4142
logger := zap.NewNop().Sugar()

internal/runtime/runtime.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2596,6 +2596,16 @@ func (r *Runtime) IsDockerAvailable() bool {
25962596
return r.dockerProbeResult
25972597
}
25982598

2599+
// GetDockerCLISource returns the coarse, fixed-enum branch that resolved the
2600+
// docker CLI — "path" | "bundled" | "login_shell" | "absent" (implements
2601+
// telemetry.RuntimeStats, schema v5 / MCP-2745). This is the direct #696 fleet
2602+
// signal (docker installed but not on the spawn PATH). It delegates to
2603+
// shellwrap.ResolveDockerSource, which shares the process-wide docker-path
2604+
// cache, so this is cheap on the heartbeat path. NEVER returns the path itself.
2605+
func (r *Runtime) GetDockerCLISource() string {
2606+
return shellwrap.ResolveDockerSource(r.logger)
2607+
}
2608+
25992609
// GetDockerIsolatedServerCount returns how many currently-configured servers
26002610
// the runtime actually wraps in a Docker container (implements
26012611
// telemetry.RuntimeStats, schema v3).

0 commit comments

Comments
 (0)