CourseModel Context Protocol: The Complete Course · Module 1: Foundations · part 12 of 77
Part 12 · Module 1: Foundations

Incident: A server that launched everywhere but the host

8 min read·13 Oct 2026
Module 1 · Incident file · About 30 minutes

What happened

08:17:57 11 Oct 2025 spawn npx ENOENT 08:18:37 11 Oct 2025 ENOENT again 08:43 11 Oct 2025 issue #6 opened 17 Nov 2025 README updated issue closed times UTC, from the issue's log and GitHub
The failure was logged and reported on 11 October 2025 (red, blue); the fix was a README change and the issue closed on 17 November 2025 (green).

rohlik-mcp is an open-source MCP server for Rohlik, an online grocery service, started with npx, Node.js's command for running a package. On 11 October 2025 a user opened issue #6 on its repository, "MCP server fails to spawn npx (ENOENT) inside Claude Desktop: needs absolute path override" (tomaspavlin/rohlik-mcp #6). Under Claude Desktop the server failed with:

text
spawn npx ENOENT
Server disconnected. Transport closed unexpectedly.

The reporter stressed that npx, node and npm were installed and worked in their shell: which npx returned a path inside a folder managed by mise, a version manager, with Node.js 22.14.0. Their log shows the host announcing the command npx, then spawn npx ENOENT twice within a millisecond, then "Server transport closed", and the same error again 40 seconds later.

CLAUDE_DESKTOP_CONFIG.JSON, COMMAND "npx" bare name spawn npx ENOENT server disconnected .../mise/installs/node/ 22.14.0/bin/npx initializes and connects
The only change was the command: the bare name failed (red), the absolute path to the same npx connected (green).

Their workaround: put the absolute path to npx in the configuration's command and restart, after which "MCP server initializes correctly and connects". On 17 November 2025 the maintainer replied that they had updated the README, and closed the issue; the README's Troubleshooting section now names the error and the absolute-path fix, and links the issue. The report describes no other harm: the cost was a server that never started for that user until they found the workaround themselves.

Why it happened

YOUR SHELL'S PATH THE HOST'S PATH mise node bin folder /usr/bin, /bin ... (not there) /usr/bin, /bin ... npx found nothing found: ENOENT
The same word, npx, is searched on two different PATH lists: the shell's has the version manager's folder (green), the host's does not (red).

The reporter named the cause: "the Claude Desktop runtime environment's PATH does not include the directory where npx resides." PATH is the list of folders a program searches when it is given a bare command name. Tools such as mise, and Python's virtual environments, add their folders to PATH inside your shell. A desktop application started another way need not have them, so the same word, npx, finds a program in your terminal and nothing in the host.

LESSON 3: SERVER STARTS, THEN EXITS THIS INCIDENT: NO SERVER AT ALL spawn server runs reason in server.log spawn: ENOENT no server only the host's log
A spawn failure happens before any server exists, so there is no server log to read; the host's own log (red) is the only record.

ENOENT is the operating system's code for "no such file or directory". Here it means every folder on the host's PATH was searched and none held npx. The failure happens at spawn, before the server exists. That is the difference from Lesson 3's broken launch: there, the server started, wrote its reason to stderr and exited. Here there is no server, so there is no server log; the only witness is the host's own log.

host's own PATH, HOME, USER ... env from config your settings child environment command looked up on that PATH everything else in your terminal is left behind
The SDK builds the child's environment from the host's own allow-listed variables (blue) plus your env (green), and the command is found, or not, on that PATH.

MCP's debugging guide warns about both halves. Servers launched over stdio "inherit only a limited subset of environment variables automatically (the exact set is platform-dependent)", and under startup problems it says: "Try using an absolute path for command" (MCP, "Debugging", checked 13 October 2026). The Python SDK behaves the same way: in mcp 2.3.0, stdio_client passes the child an allow-list of the host's own variables, including PATH, plus your env, and the operating system looks the command up on that PATH.

The same failure at notes-assistant

M01-INCIDENT/ (SIMULATED) host_config.json "notes-python" desktop_host.py stand-in host notes_server.py one tool bin/notes-python on the terminal PATH only
The replay: a Claude Desktop style configuration, a stand-in host (blue) and a launcher that only the terminal's PATH can find (green). All simulated.

Nare runs her assistant from a virtual environment, so in her terminal the server's Python is a command her shell knows. The replay builds that situation in a notebook: a one-tool notes server (Lesson 1's shape), a launcher called notes-python in a folder only "your terminal" has on its PATH, and a small stand-in host that reads a Claude Desktop style configuration. Nothing here is Claude Desktop; the host is twenty lines of Python.

python
%pip install -q mcp==2.3.0
import logging
for name in ("mcp", "httpx2", "uvicorn"):
    logging.getLogger(name).setLevel(logging.WARNING)

Code walkthrough

  • The MCP install cell from course-guide/02. This file calls no model.
python
import os, sys
from pathlib import Path

os.makedirs("m01-incident/bin", exist_ok=True)
os.chdir("m01-incident")
Path("notes_server.py").write_text('''from mcp.server import MCPServer
NOTES = {"lab-sync-2026-09-02": "Decision: run the nap study with 24 participants instead of 16."}
mcp = MCPServer("notes")

@mcp.tool()
def search_notes(query: str) -> list[str]:
    """Search Nare's notes and return the ids of notes that mention the query."""
    return [nid for nid, body in NOTES.items() if query.lower() in body.lower()]

if __name__ == "__main__":
    mcp.run()
''')
launcher = Path("bin/notes-python")
launcher.write_text(f'#!/bin/sh\nexec "{sys.executable}" "$@"\n')   # stands in for .venv/bin/python
launcher.chmod(0o755)
os.environ["PATH"] = f"{Path('bin').resolve()}{os.pathsep}{os.environ['PATH']}"   # "your terminal"
print(sorted(p.name for p in Path(".").iterdir()), "| launcher:", launcher.resolve().is_file())

Code walkthrough

  • notes_server.py is Lesson 1's one-tool server with one line of the anchor note, so this file needs nothing from the repository.
  • bin/notes-python is a two-line shell script that runs this notebook's Python, as .venv/bin/python would on Nare's computer. chmod(0o755) makes it runnable.
  • Adding bin to this notebook's PATH plays the part of activating the virtual environment in a terminal.
python
%%writefile desktop_host.py
"""A stand-in desktop host: starts each server in a config file over stdio and lists its tools. Not a real host."""
import errno, json, sys
import anyio
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main(config_path):
    for name, entry in json.load(open(config_path))["mcpServers"].items():
        print(f"[{name}] Using MCP server command: {entry['command']}")
        params = StdioServerParameters(command=entry["command"], args=entry.get("args", []), env=entry.get("env"))
        try:
            with open(f"{name}-server.log", "w") as log:
                async with Client(stdio_client(params, errlog=log)) as client:
                    tools = [t.name for t in (await client.list_tools()).tools]
            print(f"[{name}] connected, tools: {tools}")
        except OSError as err:
            print(f"[{name}] spawn {entry['command']} {errno.errorcode.get(err.errno, err.errno)}")
            print(f"[{name}] Server disconnected.")

if __name__ == "__main__":
    anyio.run(main, sys.argv[1])

Code walkthrough

  • The host reads the mcpServers entries, the format Claude Desktop uses, and starts each one with the SDK's stdio_client, server stderr going to <name>-server.log.
  • A launch that fails raises OSError; errno.errorcode turns its number into the operating system's name for it, so the log reads like the one in the issue.
  • It is a script with anyio.run, because a host is a separate program with its own environment.
SAME HOST_CONFIG.JSON, RUN TWICE terminal PATH includes bin/ connected tools: ['search_notes'] desktop PATH system folders only spawn notes-python ENOENT Server disconnected. log: ''
One configuration, two results: it connects from the terminal (green) and fails with the issue's error from the short PATH, with an empty server log (red).

Now the configuration, written the way it works in a terminal, and the host started twice: once with your terminal's PATH, once with a short one holding only the system folders, standing in for a host started from the desktop.

python
import json, subprocess

SERVER = str(Path("notes_server.py").resolve())
json.dump({"mcpServers": {"notes": {"command": "notes-python", "args": [SERVER]}}}, open("host_config.json", "w"), indent=2)
DESKTOP_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"   # system folders only: no bin/ from your terminal

def run_host(path, config="host_config.json"):
    """Start the stand-in host with a given PATH and configuration; return what it logged."""
    out = subprocess.run([sys.executable, "desktop_host.py", config],
                         env={**os.environ, "PATH": path}, capture_output=True, text=True)
    return out.stdout.strip()

print("terminal PATH:\n" + run_host(os.environ["PATH"]))
print("desktop PATH:\n" + run_host(DESKTOP_PATH))
print("notes-server.log:", repr(open("notes-server.log").read()))

Code walkthrough

  • command is the bare name notes-python; args already uses an absolute path, as Lesson 3 advised. Only the command is left to PATH.
  • run_host starts the host as its own process; env sets the PATH it starts with, and config picks the configuration file. The host is launched by its absolute Python path, so only the server's command is looked up.

You should see

text
terminal PATH:
[notes] Using MCP server command: notes-python
[notes] connected, tools: ['search_notes']
desktop PATH:
[notes] Using MCP server command: notes-python
[notes] spawn notes-python ENOENT
[notes] Server disconnected.
notes-server.log: ''

The same configuration file works from the terminal and fails from the "desktop", with the error from the issue. The server log is empty: no server ever ran to write to it.

The fix

"notes-python" bare name shutil.which in the terminal .../bin/ notes-python desktop PATH: connected run twice: no change
pin_commands turns the bare name (red) into the absolute path the terminal finds (green), and the host then connects even from the short PATH.

The reporter's fix, made safe to repeat: resolve each bare command once, where it works, and write the absolute path into the configuration. shutil.which is Python's version of which npx.

python
import shutil

def pin_commands(config):
    """Replace each bare command with the absolute path it resolves to here, where it works."""
    for name, entry in config["mcpServers"].items():
        if not os.path.isabs(entry["command"]):
            found = shutil.which(entry["command"])
            if found is None:
                raise FileNotFoundError(f"{name}: {entry['command']!r} is not on this PATH either")
            entry["command"] = found
    return config

fixed = pin_commands(json.load(open("host_config.json")))
json.dump(fixed, open("host_config.json", "w"), indent=2)
print("command now:", Path(fixed["mcpServers"]["notes"]["command"]).relative_to(Path.cwd()))
print("desktop PATH:\n" + run_host(DESKTOP_PATH))

Code walkthrough

  • os.path.isabs leaves absolute commands alone, so running the function twice changes nothing.
  • shutil.which searches this notebook's PATH, the one where the command works. If even that fails, it says so, instead of writing a broken path.
  • The last line runs the host again with the short PATH.

You should see command now: bin/notes-python, then, from the desktop PATH, the host announcing the full path to bin/notes-python and [notes] connected, tools: ['search_notes'].

COURSE-GUIDE/02, NOTES ENTRY command .../notes-assistant/.venv/bin/python args -m notes_assistant.server NOTES_DIR .../notes-assistant/notes PYTHONPATH .../notes-assistant
The notes server's desktop entry in course-guide/02 already uses absolute paths (green) for the command and for every path in env.

For the real notes server, course-guide/02's Claude Desktop entry already does this: command is the absolute path to .venv/bin/python, and NOTES_DIR and PYTHONPATH are absolute too. On Windows the SDK resolves commands itself before starting them; that path was not run here.

How you would have caught it

review step bare command? smoke test launch, short PATH config before.json 2 problems host_config.json no problems
Two cheap checks run where the bug lives: both flag the original configuration (red) and pass the fixed one (green).

The bug passed every check a developer runs from a terminal, because the terminal is where it does not happen. Two cheap checks run where it does: a review step that refuses bare commands, and a smoke test that starts the host with the short PATH.

python
def check_launch(config_path):
    """Problems a desktop host would hit: bare or missing commands, and a launch from a short PATH."""
    config = json.load(open(config_path))
    problems = []
    for name, entry in config["mcpServers"].items():
        command = entry["command"]
        if not os.path.isabs(command):
            problems.append(f"{name}: command {command!r} relies on PATH")
        elif not os.access(command, os.X_OK):
            problems.append(f"{name}: {command} is not an executable file")
    if "Server disconnected" in run_host(DESKTOP_PATH, config_path):
        problems.append("a server does not start from a short PATH")
    return problems

json.dump({"mcpServers": {"notes": {"command": "notes-python", "args": [SERVER]}}}, open("before.json", "w"))
print("before:", check_launch("before.json"))
print("after: ", check_launch("host_config.json"))

Code walkthrough

  • The first half reads the configuration only, so it can run in review before anything starts. os.access(..., os.X_OK) asks whether the file may be run.
  • run_host(DESKTOP_PATH, config_path) is the smoke test: it starts the configured servers for real, with the host's short PATH.
  • before.json is the original configuration, written again for the comparison.

You should see two problems for before.json, the bare command and the failed launch, and after: []. Either check alone would have caught it; the review step does so without starting anything.

Key points

  • A host starts a stdio server with its own environment, not your terminal's. A bare command is looked up on the host's PATH, which may lack the folders your shell adds.
  • ENOENT at spawn means the program was never found: no server ran, so there is no server log. Read the host's log.
  • Use absolute paths for command, args and every path in env, as MCP's debugging guide and the rohlik-mcp README now say.
  • Test the launch the way the host does it, with a short PATH, not only from the terminal where it works.

Sources

  • 14113 and tomaspavlin, "MCP server fails to spawn npx (ENOENT) inside Claude Desktop: needs absolute path override", tomaspavlin/rohlik-mcp issue #6, opened 11 October 2025, closed 17 November 2025. https://github.com/tomaspavlin/rohlik-mcp/issues/6
  • tomaspavlin/rohlik-mcp, README, "Troubleshooting: Server fails with spawn npx ENOENT", added in commit bd01089 "Updated readme's Troubleshooting section", 17 November 2025, fetched 13 October 2026. https://github.com/tomaspavlin/rohlik-mcp
  • Model Context Protocol, "Debugging" (working directory, environment variables, server startup), fetched 13 October 2026. https://modelcontextprotocol.io/docs/tools/debugging
  • Model Context Protocol, "Connect to local MCP servers" (troubleshooting: absolute paths, logs), fetched 13 October 2026. https://modelcontextprotocol.io/docs/develop/connect-local-servers
  • MCP Python SDK 2.3.0, mcp/client/stdio.py (DEFAULT_INHERITED_ENV_VARS, get_default_environment, stdio_client), read from the installed package, 13 October 2026. https://github.com/modelcontextprotocol/python-sdk