Why I Built This
I use Claude, Cursor, and ChatGPT daily. I wanted them to control my Windows machine — run terminal commands, read files, search code, spawn background tasks.
The problem? Most MCP servers for Windows were either:
- Cross-platform (not optimized for Windows quirks)
- Lacking security (no fail-closed defaults)
- Heavy (loaded every tool into context, wasting tokens)
So I built my own. Here's what I learned.
Lesson 1: PowerShell Has 4 Unicode Smart Quotes That Will Break Your Escapes
I thought escaping ' and " was enough. I was wrong.
Windows users paste text from Word, Outlook, and Teams. Those applications use smart quotes:
const SMART_QUOTES = {
'\u2018': "'", // left single quote
'\u2019': "'", // right single quote
'\u201A': "'", // low-9 single quote
'\u201B': "'", // high-reversed-9 single quote
};
If you don't escape these, an attacker can craft a command that bypasses your validation.
The Fix: Escape them all, plus reject NUL bytes and unescaped newlines. Better yet — pass arguments via environment variables, not string interpolation:
# Bad
powershell.exe -Command "Write-Output '${userInput}'"
# Good
$env:WH_ARG_0 = "user input here"
powershell.exe -Command "Write-Output $env:WH_ARG_0"
Lesson 2: Your API Keys Are Leaking to Subprocesses Right Now
This one hurt.
When you spawn() a subprocess in Node.js, it inherits the entire parent process environment — including process.env. If you have OPENAI_API_KEY, ANTHROPIC_API_KEY, or any sensitive token, your child processes can read them.
import { spawn } from 'node:child_process';
// This inherits EVERYTHING from process.env
spawn('some-command', [], { shell: true });
The Fix: Scrub environment variables before spawning:
function getCleanChildEnv(): NodeJS.ProcessEnv {
const clean: NodeJS.ProcessEnv = {};
const BLOCKED = /(_TOKEN|_SECRET|_KEY|_PASSWORD|AUTH)/i;
for (const [key, value] of Object.entries(process.env)) {
if (!BLOCKED.test(key)) {
clean[key] = value;
}
}
return clean;
}
spawn('some-command', [], {
shell: true,
env: getCleanChildEnv(),
});
Why this matters: CVE-2026-40159 affected a popular MCP gateway that leaked API keys to every subprocess it spawned.
Lesson 3: Path Traversal Has Unicode Bypasses
You think ../ is the only path traversal pattern? Check this:
// This bypasses most path validation
const malicious = "C:\Users\admin"; // Full-width Unicode
Windows will resolve full-width characters to their ASCII equivalents — but your regex won't catch them.
The Fix: Normalize Unicode before validation:
import path from 'node:path';
function isPathAllowed(targetPath: string, allowedDirs: string[]): boolean {
// NFKC normalization converts full-width to ASCII
const normalized = targetPath.normalize('NFKC');
const resolved = path.resolve(normalized);
return allowedDirs.some(dir =>
resolved.startsWith(path.resolve(dir))
);
}
Lesson 4: Sessions Should Have Both Sliding AND Absolute Expiration
I initially used only sliding expiration (15 minutes). But this has a flaw: sessions can live forever if the user keeps using them.
A compromised session token could remain valid for months.
The Fix: Add an absolute lifetime cap:
class SessionManager {
private ttlMs = 15 * 60 * 1000; // 15 min sliding
private absoluteTtlMs = 12 * 60 * 60 * 1000; // 12 hour absolute
validate(id: string): boolean {
const session = this.sessions.get(id);
if (!session) return false;
const now = Date.now();
// Absolute check — cannot be extended
if (now - session.createdAt > this.absoluteTtlMs) {
this.sessions.delete(id);
return false;
}
// Sliding check
if (now - session.lastAccessedAt > this.ttlMs) {
this.sessions.delete(id);
return false;
}
session.lastAccessedAt = now;
return true;
}
}
Lesson 5: One-Time Exchange Tokens Beat Long-Lived Query Tokens
Before: Users passed their master token as ?token=xxx in the URL.
Problem:
- Leaks into browser history
- Leaks into reverse proxy logs
- Leaks into referrer headers
- Can't be revoked per-session
The Fix: One-time exchange tokens that are invalidated after first use:
app.post('/auth/exchange', (req, res) => {
const { token } = req.body;
if (!safeCompare(token, currentExchangeToken)) {
return res.status(401).json({ error: 'Invalid token' });
}
// Invalidate immediately
currentExchangeToken = null;
// Issue ephemeral session cookie
const sessionId = sessionManager.createSession({ ip: req.ip });
res.setHeader('Set-Cookie',
`winhelm_session=${sessionId}; HttpOnly; SameSite=Strict; Max-Age=900`
);
res.json({ success: true });
});
Lesson 6: origin: "*" + credentials: true Doesn't Work
This one is subtle. According to the CORS spec, browsers will reject responses that have both:
Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true
So if you naively set these two, cookies won't work.
The Fix: Use dynamic origin reflection:
app.use(cors({
origin: (origin, callback) => {
// Reflect the origin back instead of using "*"
callback(null, true);
},
credentials: true,
}));
But only do this if you also validate the origin — otherwise you're opening yourself up to CSRF.
Lesson 7: null and undefined Are Not the Same in TypeScript
I had this bug for weeks and didn't notice:
// Bad
const authToken = options.authToken ?? config.authToken;
// The `??` operator treats null AND undefined as "use fallback"
// So passing `{ authToken: null }` would still pull from config
This broke my integration tests — they couldn't test the "no auth" scenario because config always had a token.
The Fix:
const authToken = options.authToken !== undefined
? options.authToken
: config.authToken;
Now null means "explicitly no auth" and undefined means "use config default."
Lesson 8: Fail-Closed by Default Is Not Enough — You Need Startup Gates
I thought "fail-closed" meant: empty allowedDirectories: [] blocks all filesystem access.
But what about network exposure? If someone runs:
node server.js --host 0.0.0.0
...without an auth token, my server would happily bind to all interfaces.
The Fix: Refuse to start:
if (!isLoopback(options.host) && !authToken) {
console.error('FATAL: Cannot bind to non-loopback without auth token');
console.error(' Set --auth <token> or use --host 127.0.0.1');
process.exit(1);
}
Fail-closed should mean fail-LOUDLY-closed.
Lesson 9: Audit Logs Need SHA-256 Hashed Session IDs
I logged session IDs in plaintext for correlation. Then I realized: if the log leaks, so does every session.
The Fix: Hash the session ID before logging:
import { createHash } from 'node:crypto';
function hashSessionId(sessionId: string): string {
return createHash('sha256')
.update(sessionId)
.digest('hex')
.slice(0, 12); // Enough for correlation, useless for hijacking
}
You can still correlate requests within a session — but you can't use the log to hijack it.
Lesson 10: Buffer Caps and Process Tree Cleanup Matter
Long-running MCP servers have two failure modes:
- Memory blowup — buffering stdout/stderr from runaway processes
- Zombie processes — orphaned child processes that never die
The Fix:
// Cap buffers
const MAX_BUFFER = 1024 * 1024; // 1MB
let stdoutSize = 0;
child.stdout.on('data', (chunk) => {
stdoutSize += chunk.length;
if (stdoutSize > MAX_BUFFER) {
child.kill('SIGTERM');
reject(new Error('Output exceeded 1MB limit'));
}
// ... process chunk
});
For process cleanup on Windows:
// Kills the entire process tree, not just the parent
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F']);
On Linux/macOS, use process groups instead.
The Results
After applying all 10 lessons:
- Tests: 154 → 219 (+65 security regression tests)
- npm audit: 0 vulnerabilities
- Attack surface: Reduced significantly
- Confidence: Much higher
You can see the full implementation: github.com/dhammawatthumpra-coder/winhelm-mcp
What I'd Do Differently Next Time
-
Start with threat modeling — I added
THREAT_MODEL.mdtoo late - Write security tests first — TDD for security is underrated
- Get independent review early — Fresh eyes catch what you miss
- Document accepted risks — Not every alert needs fixing
Final Thought
Building an MCP server is easy. Building a secure MCP server is a different game.
If you're building one, remember:
- Fail closed, fail loudly, fail often
- Assume every input is malicious
- Document what you don't protect against
- Test your security boundaries
No MCP server is 100% secure — but you can make the attacker's job much harder.
Have you built an MCP server? What security lessons did you learn? Share in the comments — I'm still learning too.
If you found this useful, the repo is here. Stars are appreciated but not required.
Top comments (0)