Fix VS Code MCP server stdio launches when running Node/pnpm inside WSL via fnm.
pnpm dlx mcp-wsl-setupOr with npx:
npx mcp-wsl-setupRun from your WSL terminal. The script auto-detects your fnm/pnpm paths and
rewrites your VS Code user mcp.json in place. Reload the VS Code window
afterwards (Ctrl+Shift+P → Developer: Reload Window).
Copy .vscode/tasks.json into your project. Then Ctrl+Shift+P →
Tasks: Run Task → Fix MCP pnpm path runs pnpm dlx mcp-wsl-setup
without leaving the editor.
How to run VS Code MCP servers (stdio transport) reliably when your development environment is WSL but VS Code is a Windows host application.
- Environment Overview
- fnm and Node.js Setup
- Shell Configuration
- The Problem
- Root Causes — In Order of Discovery
- The Solution
- Final Working Configuration
- The
resolve-pnpm.jsScript - VS Code Task
- Replicating This Setup
- Troubleshooting Reference
| Component | Detail |
|---|---|
| Host OS | Windows 11 |
| Linux environment | WSL 2 (Ubuntu) — distro name in WSL_DISTRO_NAME |
| Node manager | fnm installed inside WSL |
| Node.js | ~/.local/share/fnm/aliases/default/bin/node |
| Package manager | pnpm, bootstrapped via corepack (bundled with Node) |
| pnpm binary | ~/.local/share/fnm/aliases/default/lib/node_modules/corepack/dist/pnpm.js |
| PNPM_HOME (final) | ~/.local/share/pnpm (Linux-side only) |
| VS Code | Windows host — reads %APPDATA%\Code\User\mcp.json |
| MCP config | /mnt/c/Users/kyleb/AppData/Roaming/Code/User/mcp.json |
The key tension: VS Code runs on Windows, but all the tools live in WSL.
Every stdio MCP server VS Code starts is launched from a Windows process (or
wsl.exe) with no shell initialization, so the WSL login shell environment
(PATH, PNPM_HOME, fnm shims, keychain…) is unavailable.
fnm (Fast Node Manager) manages Node.js
versions inside WSL. It installs to ~/.local/share/fnm and creates a
default alias that points to whichever version you set as default.
fnm 1.39.0
* v20.20.1
* v22.22.1 ← default (active)
system
| Path | What it is |
|---|---|
~/.local/share/fnm/aliases/default/bin/node |
The active Node.js binary |
~/.local/share/fnm/aliases/default/bin/pnpm |
Shim → corepack pnpm.js |
~/.local/share/fnm/aliases/default/bin/corepack |
Shim → corepack.js |
~/.local/share/fnm/aliases/default/lib/node_modules/corepack/dist/pnpm.js |
The real pnpm entry point used in MCP args |
The pnpm file in bin/ is itself a shim script that calls corepack.
Corepack then resolves the real pnpm.js. Under wsl.exe -e (no shell)
this chain works, but it adds an indirection layer. The resolver script
instead points directly at pnpm.js to eliminate any shim ambiguity.
If you upgrade Node via fnm, re-run the resolver task — it reads the
default alias at runtime so the absolute paths in your MCP config
will always reflect the current default:
fnm install --lts # install a new LTS
fnm default lts-latest # update the default alias
node scripts/resolve-pnpm.js # regenerate mcp.json with new pathsIf pnpm is not yet available via corepack:
corepack enable pnpm
# verify:
pnpm --versionMCP server launches via wsl.exe -e env … do not load any shell config.
The resolver script injects everything MCP needs directly into the launch
arguments. However, your shell config still matters for interactive use and
has a direct effect on what the resolver script inherits when you run it.
# ~/.zprofile
export FNM_PATH="$HOME/.local/share/fnm"
if [ -d "$FNM_PATH" ]; then
export PATH="$FNM_PATH:$PATH"
eval "$(fnm env --shell zsh)"
fiRequired? Yes, for interactive terminals and for running the resolver
script itself. Without this, node and fnm are not on PATH in login
shells (e.g. ssh, wsl.exe -l).
Does it affect MCP launches? No. wsl.exe -e is a direct exec — it
never sources .zprofile, .zshrc, or any other startup file. All PATH
and environment setup for MCP is handled by the env PATH=… argument that
the resolver writes into mcp.json.
Note:
.zprofilehad a comment saying "including VS Code MCP launches" — this was aspirational from before we discovered thatwsl.exe -eskips all shell init. The comment can be removed or ignored.
The relevant section:
# ~/.zshrc (excerpt)
export PNPM_HOME="/mnt/d/.pnpm-store" # ← Windows-side path, should be changed
export PATH="$HOME/.local/bin:$PNPM_HOME:$BUN_INSTALL/bin:$PATH"The PNPM_HOME here should be changed to a Linux path. The
/mnt/d/.pnpm-store path works in interactive sessions but causes
cross-filesystem I/O overhead and can produce lock conflicts when pnpm
runs both from the shell and from within MCP server processes.
Recommended change in ~/.zshrc:
# Before:
export PNPM_HOME="/mnt/d/.pnpm-store"
# After:
export PNPM_HOME="$HOME/.local/share/pnpm"Then create the directory if it does not yet exist:
mkdir -p ~/.local/share/pnpmThe resolver script already forces this Linux path for MCP launches
regardless of what PNPM_HOME is set to in your shell, but aligning
your interactive shell avoids having two separate pnpm stores.
| File | Required for MCP? | Required for interactive use? | Action needed |
|---|---|---|---|
~/.zprofile (fnm init) |
No — MCP bypasses shell | Yes | Keep as-is |
~/.zshrc PNPM_HOME |
No — resolver overrides it | Yes (avoid cross-fs) | Change to ~/.local/share/pnpm |
~/.zshrc fnm PATH |
No — resolver injects PATH | Yes | Keep as-is |
After adding pnpm-based MCP servers (Playwright, Sequential Thinking, Memory) via the VS Code gallery, the servers either:
- Exited immediately with
code 1—Unknown option: 'prefer-offline' - Logged
Waiting for server to respond to initialize request…indefinitely - Logged
exec: node: not foundwith exit code 127 - Appeared to start (printed a banner to stderr) but never completed the MCP handshake
The gallery-generated server entries used:
pnpm dlx --prefer-offline @modelcontextprotocol/server-sequential-thinking@latest
Newer pnpm versions removed --prefer-offline as a dlx sub-command flag.
This caused an immediate exit code 1.
Fix: Remove --prefer-offline from all dlx invocations.
After removing --prefer-offline the servers appeared to start but hung
forever on initialize. The launch style was:
"args": ["-e", "/bin/sh", "-lc", "PATH=\"...\"; exec node pnpm.js dlx ..."]/bin/sh -lc sources /etc/profile, .profile, .bashrc, etc.
On this machine that runs keychain, which:
- Prints multi-line banners to stderr
- Waits up to 5 seconds checking for an ssh-agent lock
- All of this output lands on the same stdio channel VS Code watches for the MCP JSON-RPC handshake
VS Code sees unexpected bytes before the first {"jsonrpc":...} frame and
times out waiting for initialize.
Fix: Do not invoke a login shell. Launch node directly as an executable
using wsl.exe -e (exec-only, no shell).
When pnpm dlx downloads and runs a package (e.g. @playwright/mcp) it
creates a small wrapper script in its cache, e.g.:
~/.cache/pnpm/dlx/.../node_modules/.bin/mcp-server-memory
That script calls exec node .... Because we bypassed the login shell, the
node binary (which only exists inside fnm's tree, not in /usr/bin) is not
on PATH—so the exec fails with:
exec: node: not found
exit code 127
Fix: Use wsl.exe -e env PATH=<fnm-bin>:<std-paths> node pnpm.js dlx ....
Inject PATH explicitly without ever spawning a shell.
The inherited PNPM_HOME=/mnt/d/.pnpm-store worked for interactive WSL
sessions, but when invoked via wsl.exe -e env ... without a login shell, the
cross-filesystem path caused cache inconsistencies and slower I/O.
Fix: Force PNPM_HOME=~/.local/share/pnpm (pure Linux path) whenever
running under WSL and the inherited value starts with /mnt/.
Each iteration of resolve-pnpm.js prepended a new launch prefix to the
existing args array without fully stripping the previous format. Over
several runs the array grew:
["-e", "env", "PATH=...", "node", "pnpm.js",
"-e", "node", "pnpm.js",
"dlx", "@playwright/mcp@latest"]Fix: Add stripLegacyWslPrefixes() which iteratively strips all known
legacy prefix forms before prepending the current one, and wrap it in an outer
while (changed) loop to handle stacked layers.
Launch each stdio MCP server as a direct wsl.exe exec (no shell) with an
explicitly injected PATH that includes the fnm node binary directory:
wsl.exe -e env PATH=<fnm-bin>:<standard-paths> <abs-path-to-node> <abs-path-to-pnpm.js> dlx <package>
This approach:
- Never spawns a login shell → no keychain, no startup banners
- Passes an explicit PATH →
nodeis resolvable inside dlx-generated scripts - Uses absolute paths to node and pnpm.js → no reliance on PATH for the initial invocation
- Keeps all pnpm state in Linux (
~/.local/share/pnpm) → no cross-filesystem perf penalty
Location: %APPDATA%\Code\User\mcp.json
(WSL path: /mnt/c/Users/<user>/AppData/Roaming/Code/User/mcp.json)
{
"inputs": [
{
"id": "memory_file_path",
"type": "promptString",
"description": "Path to the memory storage file",
"password": false
},
{
"id": "Authorization",
"type": "promptString",
"description": "Authentication token (PAT or App token)",
"password": true
}
],
"servers": {
"sequentialthinking": {
"type": "stdio",
"command": "wsl.exe",
"args": [
"-e",
"env",
"PATH=/home/<user>/.local/share/fnm/aliases/default/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"/home/<user>/.local/share/fnm/aliases/default/bin/node",
"/home/<user>/.local/share/fnm/aliases/default/lib/node_modules/corepack/dist/pnpm.js",
"dlx",
"@modelcontextprotocol/server-sequential-thinking@latest"
],
"gallery": true,
"version": "0.0.1"
},
"memory": {
"type": "stdio",
"command": "wsl.exe",
"args": [
"-e",
"env",
"PATH=/home/<user>/.local/share/fnm/aliases/default/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"/home/<user>/.local/share/fnm/aliases/default/bin/node",
"/home/<user>/.local/share/fnm/aliases/default/lib/node_modules/corepack/dist/pnpm.js",
"dlx",
"@modelcontextprotocol/server-memory@latest"
],
"env": {
"MEMORY_FILE_PATH": "$${input:memory_file_path}"
},
"gallery": true,
"version": "0.0.1"
},
"playwright": {
"type": "stdio",
"command": "wsl.exe",
"args": [
"-e",
"env",
"PATH=/home/<user>/.local/share/fnm/aliases/default/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"/home/<user>/.local/share/fnm/aliases/default/bin/node",
"/home/<user>/.local/share/fnm/aliases/default/lib/node_modules/corepack/dist/pnpm.js",
"dlx",
"@playwright/mcp@latest",
"--caps=vision"
]
}
}
}Replace <user> with your WSL username. The resolve-pnpm.js script fills in
the correct paths automatically—you should not hand-edit those paths.
scripts/resolve-pnpm.js is an idempotent Node.js script that auto-detects the
correct launch configuration for your environment and rewrites your user-level
mcp.json in place. It is safe to run repeatedly.
- Detects whether it is running under WSL and whether the target config file
lives on the Windows filesystem (
/mnt/c/…). - Resolves
PNPM_HOME— forces a Linux-side path (~/.local/share/pnpm) when running in WSL to avoid/mnt/cross-filesystem issues. - Finds the fnm-managed
nodeandcorepack/pnpm.jsabsolute paths. - Builds the safe WSL launch prefix:
wsl.exe -e env PATH=<fnm-bin>:<std> <node> <pnpm.js> - Strips any previously written legacy launch prefixes from existing
argsarrays (handles multiple layered formats idempotently). - Rewrites all stdio-type servers in the target config, skipping HTTP/SSE
servers that have no
command.
| Flag | Default | Description |
|---|---|---|
--server <name> |
all stdio servers in target config | Update only this server |
--target <path> |
auto-detected per platform | Path to mcp.json to write |
--workspace <path> |
.vscode/mcp.json in cwd |
Workspace MCP config to read server names from |
# Update all stdio servers in user mcp.json (normal usage):
node scripts/resolve-pnpm.js
# Update only the playwright server:
node scripts/resolve-pnpm.js --server playwright
# Dry-run against a test file:
node scripts/resolve-pnpm.js --target /tmp/test-mcp.json1. fnm node + corepack pnpm.js exist?
→ wsl.exe -e env PATH=<fnm-bin>:… <node> <pnpm.js> dlx … ✓ used
2. `which pnpm` finds a binary?
→ wsl.exe -e env PATH=<pnpm-dir>:… pnpm dlx …
3. Fallback
→ plain `pnpm` with PATH/PNPM_HOME env vars injected
.vscode/tasks.json registers a task that runs the resolver with one command:
{
"version": "2.0.0",
"tasks": [
{
"label": "Fix MCP pnpm path",
"type": "shell",
"command": "node",
"args": ["scripts/resolve-pnpm.js"],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
}
]
}Run it: Ctrl+Shift+P → Tasks: Run Task → Fix MCP pnpm path
After the task completes, reload the VS Code window (Ctrl+Shift+P →
Developer: Reload Window) to pick up the changed MCP config.
- WSL 2 with Ubuntu (or any distro)
- fnm installed in WSL (
curl -fsSL https://fnm.vercel.app/install | bash) - fnm init in
~/.zprofile(or~/.profilefor bash) — see Shell Configuration - Node.js active via fnm (
fnm install --lts && fnm default lts-latest) - pnpm accessible via corepack (
corepack enable pnpm) PNPM_HOMEset to a Linux path in~/.zshrc(export PNPM_HOME="$HOME/.local/share/pnpm")
-
Copy the files into your project:
scripts/resolve-pnpm.js .vscode/tasks.json -
Adjust the hardcoded username in
resolveTargetPath()if your Windows username is notkyleb:// In resolveTargetPath(), line ~40: return path.join("/mnt/c", "Users", "YOUR_WINDOWS_USERNAME", "AppData", ...);
Or pass
--targetexplicitly to override entirely. -
Run the resolver once from your WSL terminal:
node scripts/resolve-pnpm.js
-
Reload VS Code window — the MCP servers should start without hanging.
-
For future MCP servers added via the gallery, re-run the resolver or the VS Code task after adding them.
# Node binary (fnm)
ls ~/.local/share/fnm/aliases/default/bin/node
# pnpm.js (corepack)
ls ~/.local/share/fnm/aliases/default/lib/node_modules/corepack/dist/pnpm.js
# Linux PNPM_HOME
ls ~/.local/share/pnpmIf corepack is not present, run:
corepack enable pnpmYour MCP config still has --prefer-offline in the dlx args. Run the
resolver task — it removes this flag automatically.
The server is still being launched via /bin/sh -lc. Re-run the resolver task
to switch to the direct wsl.exe -e env … format.
PATH is not being injected into the WSL process. Re-run the resolver — the
current version adds an explicit env PATH=… argument before the node binary.
An older version of the script. Pull the latest resolve-pnpm.js, which
includes stripLegacyWslPrefixes() with an outer while (changed) loop that
clears all stacked legacy formats before writing.
Add PNPM_HOME=~/.local/share/pnpm to your WSL ~/.bashrc / ~/.profile, or
unset the variable — the resolver will substitute the Linux default. Then run
the resolver again.
Check that PNPM_HOME is not set to a Windows path in your shell profile.
Cross-filesystem I/O (/mnt/d/…) can cause pnpm's store to lock or time out
under wsl.exe -e.
- Open the VS Code Output panel (
Ctrl+Shift+U) and select the MCP server from the dropdown. - Look for the first stderr lines — they reveal whether the failure is in the WSL bootstrap, pnpm, or the server itself.
- Reproduce in your WSL terminal by copy-pasting the
wsl.execommand from the generatedmcp.jsonargsarray — prependwsl.exeas the binary and pass each array element as a separate positional argument.