Sandboxing · Worker Threads · Node.js
MCP server worker thread isolation security
When an MCP tool handler processes untrusted content — user-supplied file paths, web-fetched documents, LLM-generated arguments — the processing code runs with access to the main thread's heap: your authentication tokens, database connections, and the full process.env credential set. Node.js Worker threads move tool handler execution into a separate memory space with no shared heap, isolating untrusted code from the main process state.
Why Worker threads for MCP server isolation
Three properties make Worker threads valuable for MCP tool handlers specifically:
Memory isolation without subprocess overhead. A child process (child_process.fork()) provides stronger isolation (separate process space) but requires serializing all arguments and results across a pipe, and spawning a new process for each tool call has 50–200ms overhead per call. Worker threads share the same process address space with separate heap allocations — message passing is still serialized, but thread startup is 2–5ms.
Credential-free execution environment. Workers do not inherit the parent's workerData unless you explicitly pass it. If you initialize a Worker without passing process.env as workerData, the Worker thread cannot read credentials from the environment — those values live on the parent thread's heap.
Terminatable on timeout. A Worker thread that has been processing for too long can be terminated with worker.terminate() without taking down the main process. This prevents a tool handler stuck on CPU-intensive or infinite-looping untrusted code from blocking the MCP server's event loop.
Worker threads are not a security boundary equivalent to a separate process or container. A malicious npm module that uses native addons (node-gyp compiled C++) or Node.js internals can sometimes escape Worker isolation. For the highest-risk tool handlers (those executing arbitrary code or running third-party plugins), combine Worker threads with a container or seccomp boundary. Worker threads address the most common risk: tool handler bugs that accidentally access or log main-thread credentials.
Basic Worker thread pattern for MCP tool handlers
// tool-runner.js — the Worker thread script
import { workerData, parentPort } from 'node:worker_threads';
// workerData contains only what the parent explicitly passed — NOT process.env
const { toolName, args, allowedOps } = workerData;
async function runTool() {
// Tool logic here — no access to parent heap or credentials
if (toolName === 'process_document') {
const result = await processDocument(args.content, allowedOps);
parentPort.postMessage({ ok: true, result });
} else {
parentPort.postMessage({ ok: false, error: 'Unknown tool' });
}
}
runTool().catch(err => {
// Never include stack traces or internal paths in the error message
parentPort.postMessage({ ok: false, error: 'Tool execution failed' });
});
// ---
// main-server.js — parent thread, hosts MCP server
import { Worker } from 'node:worker_threads';
import path from 'node:path';
const TOOL_TIMEOUT_MS = 10_000;
async function runToolInWorker(toolName, args, allowedOps) {
return new Promise((resolve, reject) => {
const worker = new Worker(
path.resolve('./tool-runner.js'),
{
workerData: { toolName, args, allowedOps },
// Do NOT pass env: process.env — Worker gets a minimal env by default
env: {
NODE_ENV: process.env.NODE_ENV,
// Pass only what the tool legitimately needs — never the full env
}
}
);
const timeout = setTimeout(() => {
worker.terminate();
reject(new ToolError('TIMEOUT', 'Tool execution timed out'));
}, TOOL_TIMEOUT_MS);
worker.on('message', (msg) => {
clearTimeout(timeout);
if (msg.ok) resolve(msg.result);
else reject(new ToolError('TOOL_ERROR', msg.error));
});
worker.on('error', (err) => {
clearTimeout(timeout);
reject(new ToolError('WORKER_ERROR', 'Worker thread failed'));
});
worker.on('exit', (code) => {
clearTimeout(timeout);
if (code !== 0) reject(new ToolError('WORKER_EXIT', `Worker exited with code ${code}`));
});
});
}
What not to pass as workerData
The most common Worker thread security mistake is passing the parent's full environment or database connection objects as workerData. Because workerData is structured-cloned (not a live reference for plain objects), database connection objects cannot be passed directly — but environment variable maps can be inadvertently passed:
// WRONG — passes entire credential set to the Worker
new Worker('./tool-runner.js', {
workerData: { args, env: process.env }
});
// WRONG — same problem, less obvious
new Worker('./tool-runner.js', {
env: process.env // This IS passed and readable in the Worker as process.env
});
// CORRECT — pass a credential-free allowlist
new Worker('./tool-runner.js', {
workerData: { toolName, args, allowedPaths: ['/data/uploads/'] },
env: { NODE_ENV: 'production' } // only what the tool actually needs
});
SharedArrayBuffer hazards
SharedArrayBuffer is the one exception to Worker memory isolation — it creates a region of memory that both the parent thread and the Worker can read and write concurrently. For MCP servers, avoid passing SharedArrayBuffer to Workers unless you have a specific high-throughput reason, because:
A buggy or malicious Worker can write to shared memory before the parent validates its output, corrupting the parent's view of the result. There is no revocation mechanism — once a SharedArrayBuffer is shared with a Worker, the Worker retains access to that memory region until the Worker terminates. For tool handlers that process untrusted content, use postMessage with structured-clone serialization instead — it is slower but provides a clean isolation boundary.
Worker thread pool for production
Creating a new Worker on every tool call adds 2–5ms startup latency. For high-throughput MCP servers, maintain a pool of pre-warmed Workers:
// Minimal Worker pool — keeps N idle Workers ready
class WorkerPool {
#idle = [];
#size;
#script;
constructor(script, size = 4) {
this.#script = script;
this.#size = size;
for (let i = 0; i < size; i++) this.#idle.push(this.#spawn());
}
#spawn() {
return new Worker(this.#script, { env: { NODE_ENV: process.env.NODE_ENV } });
}
async run(workerData) {
const worker = this.#idle.pop() ?? this.#spawn();
try {
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => { worker.terminate(); reject(new Error('timeout')); }, 10_000);
worker.once('message', (msg) => { clearTimeout(timeout); resolve(msg); });
worker.once('error', reject);
worker.postMessage(workerData);
});
} finally {
// Return a fresh Worker to the pool (terminated workers are not reused)
if (!worker[Symbol.for('terminated')]) this.#idle.push(worker);
if (this.#idle.length < this.#size) this.#idle.push(this.#spawn());
}
}
}
const pool = new WorkerPool('./tool-runner.js', 4);
SkillAudit findings for Worker thread patterns
err.message or err.stack back to the parent and the parent relays it to the MCP response, internal implementation details leak. Grade impact: −5.
Related: MCP server sandboxing and isolation overview · vm.runInContext sandbox security · Error handling best practices