TLDR: Anything launched by something other than you-in-the-project-dir — MCP server, launchd daemon, cron job — must resolve
.envrelative toimport.meta.url, notprocess.cwd().cwdwill lie to you every time.
The Setup
I maintain a QuickBooks Online MCP server (a plugin that lets Claude talk directly to a live QuickBooks Online account — read invoices, pull reports, that sort of thing).
QBO uses OAuth. Tokens rotate on every use.
So the server needs to write the fresh token back to .env after every refresh, or the next call fails.
Simple enough.
Except…
What Broke
The tokens kept silently dying.
I'd auth, it'd work, a few hours later — Claude calls a tool, QBO rejects the token, dead in the water.
My first suspect was the rotation logic itself. If you get a new token and don't persist it immediately, QBO locks you out. I dug in and found a real bug there — the rotated token wasn't always being written back. Fixed it.
Problem still there.
What I Tried That Didn't Fully Work
The rotation fix was real, but it wasn't the root.
Because the token was being written. Just… somewhere else.
The Fix That Actually Worked
Here's what I'd missed: when Claude launches an MCP server, the client spawns the process. The working directory — process.cwd() — is wherever Claude happens to be sitting. Not your project folder.
So this code:
const envPath = path.resolve('.env')
Was resolving to something like ~. The .env file sitting right next to index.ts? Completely invisible to Node.
The fix — anchor to the module itself, not the ambient directory:
import { fileURLToPath } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const envPath = path.join(__dirname, '../.env')
Now it resolves relative to where the file lives, not where the process was started from.
Stable. Every time. No matter who or what launches it.
Three commits to get there. The callback robustness fix first, then the rotation logic, then finally the actual root cause.
I wasn't chasing the wrong thing — those first two bugs were real. I just hadn't found the bottom yet.
The Broader Pattern
This shows up EVERYWHERE once you've burned yourself on it.
MCP servers. launchd daemons. Cron scripts. CI runners. Anything launched outside of you, in your terminal, in your project folder has an unpredictable cwd.
The file is there. Node is just not looking there.
(There's a cousin bug worth knowing about: ES module import hoisting. If a module reads process.env.DATABASE_URL at load time — say, to open a DB connection — a dotenv.config() call on the line above the import is already too late. Static imports hoist to the top before any code runs. Same general lesson — "the ambient context you assumed was set up wasn't" — different mechanism.)
The rule I now write into every server-side module that touches files: if a path matters, resolve it from import.meta.url. Treat process.cwd() as a stranger.
Small habit. The debugging time it saves is NOT small.