Skip to content

Commit 917c1c5

Browse files
committed
feat(diagnostics): make MCPX_DOCKER_EXEC_NOT_FOUND runtime-aware
The static DockerExecNotFound catalog message ("the image has no uvx/node") is too generic to self-resolve. A field report (ElevenLabs / uvx) hit it because of a per-server `isolation.image: "python:3.11"` override — stock python has no uvx — but the owner misattributed it to recent host-side docker PATH fixes. Enrich the remediation with per-server context: name the detected runtime (uvx/npx/pipx/…), the recommended runtime-default image (default_images[runtimeType]), and flag a per-server isolation.image override as the likely culprit when it differs from the default. - ClassifierHints gains DockerCommand / DockerImageOverride / DockerDefaultImages (diagnostics-only; never affect classification). - RuntimeAwareRemediation(code, hints) builds the enriched message; DiagnosticError.Remediation carries it; the REST/status serializers prefer it over the static UserMessage when present. - detectDockerRuntimeType mirrors core.DetectRuntimeType for display (diagnostics must not import upstream/core). - Add docs/errors/MCPX_DOCKER_EXEC_NOT_FOUND.md + README index entry. Image selection and TransformCommandForContainer are unchanged (correct since #477) — this is diagnostics-only. Related MCP-2909
1 parent 6998910 commit 917c1c5

8 files changed

Lines changed: 344 additions & 21 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
id: MCPX_DOCKER_EXEC_NOT_FOUND
3+
title: MCPX_DOCKER_EXEC_NOT_FOUND
4+
sidebar_label: EXEC_NOT_FOUND
5+
description: The Docker isolation image is missing the interpreter the server needs (e.g. no uvx/node).
6+
---
7+
8+
# `MCPX_DOCKER_EXEC_NOT_FOUND`
9+
10+
**Severity:** error
11+
**Domain:** Docker
12+
13+
## What happened
14+
15+
The container started, but its entrypoint interpreter was not found *inside* the
16+
image. The failure is reported by the OCI runtime (`runc`) at exec time, e.g.:
17+
18+
```
19+
exec: "uvx": executable file not found in $PATH
20+
```
21+
22+
This means Docker isolation worked — the image just doesn't contain the tool the
23+
server's command needs.
24+
25+
## Common cause: a per-server image override that lacks the interpreter
26+
27+
The usual culprit is a per-server `isolation.image` override pointing at a stock
28+
image that doesn't bundle the runtime. The classic example is a `uvx` server
29+
pinned to `python:3.11`:
30+
31+
```jsonc
32+
{
33+
"name": "elevenlabs",
34+
"command": "uvx",
35+
"args": ["elevenlabs-mcp"],
36+
"isolation": { "image": "python:3.11" } // ❌ stock python has no uvx
37+
}
38+
```
39+
40+
`uvx` ships with [Astral's `uv`](https://github.com/astral-sh/uv), which is a
41+
separate tool — `python:3.11` does not include it. So
42+
`docker run python:3.11 uvx …` fails at exec time.
43+
44+
When mcpproxy detects this, the diagnostic names the **detected runtime**, the
45+
**recommended image** for it, and flags the per-server override as the likely
46+
culprit.
47+
48+
## How to fix
49+
50+
### Remove the per-server override to inherit the runtime default (recommended)
51+
52+
Each runtime has a default image that includes the right interpreter
53+
(`default_images` in `docker_isolation`). For `uvx`/`pip`/`pipx`/`python` the
54+
default is `ghcr.io/astral-sh/uv:python3.13-bookworm-slim`; for
55+
`npx`/`npm`/`node`/`yarn` it is `node:22`. Drop the override:
56+
57+
```jsonc
58+
{ "name": "elevenlabs", "command": "uvx", "args": ["elevenlabs-mcp"] }
59+
```
60+
61+
### Or pick an image that includes the interpreter
62+
63+
If you must pin a specific image, choose one that bundles the tool:
64+
65+
```jsonc
66+
{ "isolation": { "image": "ghcr.io/astral-sh/uv:python3.11-bookworm-slim" } }
67+
```
68+
69+
### Verify the image has the tool
70+
71+
```bash
72+
docker run --rm <image> which uvx # or: node, npx, python3 …
73+
```
74+
75+
## Related
76+
77+
- [Docker Isolation](../docker-isolation.md)
78+
- [`MCPX_DOCKER_IMAGE_PULL_FAILED`](MCPX_DOCKER_IMAGE_PULL_FAILED.md)
79+
- [`MCPX_DOCKER_DAEMON_DOWN`](MCPX_DOCKER_DAEMON_DOWN.md)
80+
</content>
81+
</invoke>

docs/errors/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ Run `mcpproxy doctor list-codes` for the machine-readable list.
6868

6969
- [`MCPX_DOCKER_DAEMON_DOWN`](MCPX_DOCKER_DAEMON_DOWN.md) — daemon unreachable
7070
- [`MCPX_DOCKER_IMAGE_PULL_FAILED`](MCPX_DOCKER_IMAGE_PULL_FAILED.md) — pull failed
71+
- [`MCPX_DOCKER_EXEC_NOT_FOUND`](MCPX_DOCKER_EXEC_NOT_FOUND.md) — image missing the runtime interpreter (e.g. no `uvx`)
7172
- [`MCPX_DOCKER_NO_PERMISSION`](MCPX_DOCKER_NO_PERMISSION.md) — socket permission denied
7273
- [`MCPX_DOCKER_SNAP_APPARMOR`](MCPX_DOCKER_SNAP_APPARMOR.md) — snap Docker AppArmor block
7374

internal/diagnostics/classifier_domains_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package diagnostics
22

33
import (
44
"errors"
5+
"strings"
56
"testing"
67
)
78

@@ -209,3 +210,73 @@ func TestClassify_Quarantine_ToolChanged(t *testing.T) {
209210
t.Errorf("Classify(tool_changed) = %q, want %q", got, QuarantineToolChanged)
210211
}
211212
}
213+
214+
// --- RUNTIME-AWARE REMEDIATION (MCP-2909) -----------------------------------
215+
216+
// TestRuntimeAwareRemediation_DockerExecNotFound covers the field-report case
217+
// (ElevenLabs / uvx / per-server image override): a `uvx` server pinned to a
218+
// stock `python:3.11` image fails at exec time because that image has no `uvx`.
219+
// The enriched DockerExecNotFound remediation must name the detected runtime,
220+
// the recommended runtime-default image, and flag the per-server override as the
221+
// likely culprit when it differs from the default.
222+
func TestRuntimeAwareRemediation_DockerExecNotFound(t *testing.T) {
223+
const uvImage = "ghcr.io/astral-sh/uv:python3.13-bookworm-slim"
224+
defaults := map[string]string{
225+
"uvx": uvImage,
226+
"pipx": uvImage,
227+
"npx": "node:22",
228+
}
229+
230+
t.Run("uvx_on_bare_python_override", func(t *testing.T) {
231+
msg := RuntimeAwareRemediation(DockerExecNotFound, ClassifierHints{
232+
DockerCommand: "uvx",
233+
DockerImageOverride: "python:3.11",
234+
DockerDefaultImages: defaults,
235+
})
236+
// Must name the detected runtime.
237+
if !strings.Contains(msg, "uvx") {
238+
t.Errorf("message must name the runtime 'uvx'; got: %q", msg)
239+
}
240+
// Must name the recommended image.
241+
if !strings.Contains(msg, uvImage) {
242+
t.Errorf("message must name recommended image %q; got: %q", uvImage, msg)
243+
}
244+
// Must flag the per-server override as the culprit.
245+
if !strings.Contains(msg, "python:3.11") {
246+
t.Errorf("message must name the failing override image 'python:3.11'; got: %q", msg)
247+
}
248+
if !strings.Contains(strings.ToLower(msg), "override") {
249+
t.Errorf("message must flag the per-server override; got: %q", msg)
250+
}
251+
})
252+
253+
t.Run("npx_no_override_still_names_runtime_and_image", func(t *testing.T) {
254+
msg := RuntimeAwareRemediation(DockerExecNotFound, ClassifierHints{
255+
DockerCommand: "npx",
256+
DockerDefaultImages: defaults,
257+
})
258+
if !strings.Contains(msg, "npx") {
259+
t.Errorf("message must name the runtime 'npx'; got: %q", msg)
260+
}
261+
if !strings.Contains(msg, "node:22") {
262+
t.Errorf("message must name recommended image 'node:22'; got: %q", msg)
263+
}
264+
})
265+
266+
t.Run("no_enrichment_without_command", func(t *testing.T) {
267+
if msg := RuntimeAwareRemediation(DockerExecNotFound, ClassifierHints{
268+
DockerDefaultImages: defaults,
269+
}); msg != "" {
270+
t.Errorf("no docker command → empty enrichment (fall back to static catalog); got: %q", msg)
271+
}
272+
})
273+
274+
t.Run("no_enrichment_for_other_codes", func(t *testing.T) {
275+
if msg := RuntimeAwareRemediation(DockerCLINotFound, ClassifierHints{
276+
DockerCommand: "uvx",
277+
DockerDefaultImages: defaults,
278+
}); msg != "" {
279+
t.Errorf("only DockerExecNotFound is enriched; got: %q", msg)
280+
}
281+
})
282+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package diagnostics
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
// RuntimeAwareRemediation returns an enriched, context-specific remediation
10+
// message for codes that support per-server enrichment, or "" to fall back to
11+
// the static CatalogEntry.UserMessage.
12+
//
13+
// Today it enriches only DockerExecNotFound (MCP-2909): the in-container
14+
// interpreter was missing because the chosen Docker image lacks it. The field
15+
// report that motivated this — a `uvx` server pinned via a per-server
16+
// `isolation.image: "python:3.11"` override — failed at exec time because stock
17+
// `python:3.11` has no `uvx` (uv is a separate Astral tool). The static catalog
18+
// message is too generic to self-resolve, so we name (a) the detected runtime,
19+
// (b) the recommended runtime-default image, and (c) when a per-server image
20+
// override is the likely culprit.
21+
//
22+
// This is diagnostics-only: it never changes classification or image selection.
23+
func RuntimeAwareRemediation(code Code, hints ClassifierHints) string {
24+
if code != DockerExecNotFound || hints.DockerCommand == "" {
25+
return ""
26+
}
27+
28+
runtimeType := detectDockerRuntimeType(hints.DockerCommand)
29+
recommended := hints.DockerDefaultImages[runtimeType]
30+
override := strings.TrimSpace(hints.DockerImageOverride)
31+
32+
var b strings.Builder
33+
fmt.Fprintf(&b, "This `%s` server's Docker image has no `%s` interpreter, so the container could not start it.", runtimeType, runtimeType)
34+
35+
if override != "" {
36+
fmt.Fprintf(&b, " The per-server `isolation.image` override `%s` is the likely culprit", override)
37+
if recommended != "" && override != recommended {
38+
fmt.Fprintf(&b, " — it differs from the recommended image for `%s`", runtimeType)
39+
}
40+
b.WriteString(".")
41+
}
42+
43+
switch {
44+
case recommended != "" && override != "":
45+
fmt.Fprintf(&b, " The recommended image for `%s` is `%s`. Remove the per-server `isolation.image` override to inherit it, or pick an image that includes `%s`.", runtimeType, recommended, runtimeType)
46+
case recommended != "":
47+
fmt.Fprintf(&b, " The recommended image for `%s` is `%s`. Pick an image that includes `%s`.", runtimeType, recommended, runtimeType)
48+
default:
49+
fmt.Fprintf(&b, " Pick an image that includes `%s`.", runtimeType)
50+
}
51+
52+
return b.String()
53+
}
54+
55+
// detectDockerRuntimeType maps a server's configured command to its runtime
56+
// type key (the same keys used by config.DockerIsolationConfig.DefaultImages).
57+
//
58+
// It is a deliberately small, side-effect-free mirror of
59+
// core.IsolationManager.DetectRuntimeType (internal/upstream/core/isolation.go)
60+
// — the diagnostics package must not import upstream/core, and (like
61+
// supervisor.usesDockerIsolation mirrors ShouldIsolate) faithfulness for the
62+
// display path matters more than sharing the implementation. Unknown commands
63+
// fall back to the base command name so the message still names something
64+
// concrete rather than a generic "interpreter".
65+
func detectDockerRuntimeType(command string) string {
66+
cmdName := filepath.Base(command)
67+
switch cmdName {
68+
case "python", "python3", "python3.11", "python3.12", "python3.13":
69+
return "python"
70+
case "uvx":
71+
return "uvx"
72+
case "pip", "pip3":
73+
return "pip"
74+
case "pipx":
75+
return "pipx"
76+
case "node":
77+
return "node"
78+
case "npm":
79+
return "npm"
80+
case "npx":
81+
return "npx"
82+
case "yarn":
83+
return "yarn"
84+
case "go":
85+
return "go"
86+
case "cargo":
87+
return "cargo"
88+
case "rustc":
89+
return "rustc"
90+
case "ruby":
91+
return "ruby"
92+
case "gem":
93+
return "gem"
94+
case "php":
95+
return "php"
96+
case "composer":
97+
return "composer"
98+
default:
99+
lower := strings.ToLower(cmdName)
100+
if strings.Contains(lower, "python") {
101+
return "python"
102+
}
103+
if strings.Contains(lower, "node") {
104+
return "node"
105+
}
106+
return cmdName
107+
}
108+
}

internal/diagnostics/types.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,16 @@ type CatalogEntry struct {
5454
// DiagnosticError is the runtime record attached to a server's stateview snapshot
5555
// while the server has an active failure.
5656
type DiagnosticError struct {
57-
Code Code `json:"code"`
58-
Severity Severity `json:"severity"`
59-
Cause string `json:"cause,omitempty"`
60-
CauseType string `json:"cause_type,omitempty"`
61-
ServerID string `json:"server_id"`
62-
DetectedAt time.Time `json:"detected_at"`
57+
Code Code `json:"code"`
58+
Severity Severity `json:"severity"`
59+
Cause string `json:"cause,omitempty"`
60+
// Remediation is an optional runtime-aware, context-specific user message
61+
// that overrides the static CatalogEntry.UserMessage when present (MCP-2909).
62+
// Empty when the generic catalog message is sufficient.
63+
Remediation string `json:"remediation,omitempty"`
64+
CauseType string `json:"cause_type,omitempty"`
65+
ServerID string `json:"server_id"`
66+
DetectedAt time.Time `json:"detected_at"`
6367
}
6468

6569
// ClassifierHints lets callers nudge the classifier when context is known
@@ -73,6 +77,25 @@ type ClassifierHints struct {
7377
// per #696, in-container interpreter missing) instead of a generic
7478
// MCPX_STDIO_SPAWN_ENOENT. See classifyDockerIsolatedSpawn.
7579
DockerIsolated bool
80+
81+
// The fields below enrich the DockerExecNotFound remediation with
82+
// per-server context (MCP-2909). They are diagnostics-only — they never
83+
// change classification, only the user-facing message produced by
84+
// RuntimeAwareRemediation.
85+
86+
// DockerCommand is the configured stdio command for a Docker-isolated
87+
// server (e.g. "uvx", "npx"). The detected runtime type is derived from it.
88+
// Empty when unknown or for non-isolated servers.
89+
DockerCommand string
90+
91+
// DockerImageOverride is the per-server isolation.image override, if any.
92+
// When set, the DockerExecNotFound remediation flags it as the likely
93+
// culprit (a stock image that lacks the runtime interpreter).
94+
DockerImageOverride string
95+
96+
// DockerDefaultImages is the global default_images map (runtime → image).
97+
// Used to name the recommended image for the detected runtime.
98+
DockerDefaultImages map[string]string
7699
}
77100

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

internal/runtime/runtime.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1934,7 +1934,14 @@ func (r *Runtime) GetAllServers() ([]map[string]interface{}, error) {
19341934
"detected_at": d.DetectedAt,
19351935
}
19361936
if entry, ok := diagnostics.Get(d.Code); ok {
1937-
diagMap["user_message"] = entry.UserMessage
1937+
// MCP-2909: prefer the runtime-aware remediation when present so
1938+
// the user sees the detected runtime + recommended image instead
1939+
// of the generic catalog message.
1940+
if d.Remediation != "" {
1941+
diagMap["user_message"] = d.Remediation
1942+
} else {
1943+
diagMap["user_message"] = entry.UserMessage
1944+
}
19381945
diagMap["fix_steps"] = entry.FixSteps
19391946
diagMap["docs_url"] = entry.DocsURL
19401947
}

0 commit comments

Comments
 (0)