Incident: A server that launched everywhere but the host
Module 1 · Incident file · About 30 minutes
What happened
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:
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.
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
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.
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.
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
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.
%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.
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.pyis Lesson 1's one-tool server with one line of the anchor note, so this file needs nothing from the repository.bin/notes-pythonis a two-line shell script that runs this notebook's Python, as.venv/bin/pythonwould on Nare's computer.chmod(0o755)makes it runnable.- Adding
binto this notebook'sPATHplays the part of activating the virtual environment in a terminal.
%%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
mcpServersentries, the format Claude Desktop uses, and starts each one with the SDK'sstdio_client, server stderr going to<name>-server.log. - A launch that fails raises
OSError;errno.errorcodeturns 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.
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.
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
commandis the bare namenotes-python;argsalready uses an absolute path, as Lesson 3 advised. Only the command is left toPATH.run_hoststarts the host as its own process;envsets thePATHit starts with, andconfigpicks the configuration file. The host is launched by its absolute Python path, so only the server's command is looked up.
You should see
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
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.
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.isabsleaves absolute commands alone, so running the function twice changes nothing.shutil.whichsearches this notebook'sPATH, 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'].
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
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.
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 shortPATH.before.jsonis 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,argsand every path inenv, 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