The minimal-footprint MCP server: building for security from the ground up
Most MCP server security problems are architectural, not code-level. They stem from decisions made in the first hour: which transport to use, how many npm packages to pull in, how broadly to scope credentials, and whether to pass arguments to a shell. This guide walks through six architectural choices that collectively produce A-grade security scores — and explains why each choice matters before the first line of business logic is written.
Why architecture beats remediation
When SkillAudit scans an MCP server that scores in the C or F range, the most common finding is not a subtle bug — it is a structural choice that made an entire class of vulnerabilities inevitable. A server that uses child_process.exec() for shell operations cannot be made safe by sanitizing inputs; the architecture guarantees that any bypass in the sanitization layer produces remote code execution. A server that shares a single database credential across all tools cannot be made safe by adding more audit logging; the architecture guarantees that a credential leak in any tool exposes all data.
The right time to make architectural decisions is before writing business logic, not after. Retrofitting minimal-footprint architecture onto an existing server means rewriting half of it. Getting it right from the start costs almost nothing — the constraints are actually simpler to implement than the alternatives.
Six principles govern the minimal-footprint MCP server. Each one rules out an entire class of findings before any code is written.
Principle 1
stdio-only transport
No HTTP server, no port binding, no network attack surface. The client communicates over stdin/stdout.
Principle 2
Zero external dependencies
No npm packages that are not the MCP SDK itself. Every dependency is a supply-chain risk and a maintenance burden.
Principle 3
Per-tool credential isolation
Each tool gets the minimum credential scope it needs. A leak in one tool does not expose another tool's data.
Principle 4
No shell invocation
No exec(), spawn(shell:true), or template-interpolated commands. Use native APIs or typed argument arrays.
Principle 5
Declarative permission manifest
Every resource the server accesses is declared in a static manifest before runtime. Nothing is accessed that is not listed.
Principle 6
Immutable append-only audit log
Every tool invocation, argument set, and result is written to a log that cannot be overwritten by the tool itself.
Principle 1 — stdio-only transport
An HTTP server is a network endpoint. It has a port. It has a TLS stack. It has an HTTP parser. It has routing middleware. Each layer is a potential vulnerability surface. A stdio server has none of these — it talks to exactly one client over two file descriptors.
The majority of MCP servers in the SkillAudit public scan corpus that scored F on the Security axis were running HTTP transports with misconfigured CORS headers, unvalidated Host headers, or authentication middleware that could be bypassed by manipulating request routing. Every one of those vulnerabilities is impossible on a stdio server because there is no network layer to misconfigure.
The MCP SDK makes stdio-only transport trivial to implement:
// Node.js — stdio transport, zero network surface
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "my-mcp-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Register tools here
const transport = new StdioServerTransport();
await server.connect(transport);
// No app.listen(), no port, no network
If your use case genuinely requires an HTTP transport — for example, a multi-tenant hosted server — that is a different architectural category and requires a substantially more complex security posture. The minimal-footprint approach is stdio. If you find yourself reaching for HTTP, ask first whether you can restructure the use case to avoid it.
The stdio transport security boundary: A stdio server runs as a subprocess of the MCP client. The client controls its lifecycle, input, and output. The server's only communication channel is with that client. This means the trust model is simple: the client is trusted, everything else (environment variables, file system, external services) requires its own validation layer.
Principle 2 — zero external dependencies
Every npm package you add is a dependency with its own dependencies, its own maintainer, its own vulnerability history, and its own update cadence. A minimal-footprint MCP server uses only the MCP SDK — no axios, no lodash, no yaml-js, no moment, no anything else.
This is more achievable than it sounds. The Node.js standard library covers most of what an MCP server needs: node:fs for file operations, node:https for HTTP calls, node:crypto for random token generation, node:path for path manipulation. The cases where you genuinely cannot avoid a dependency are narrower than the cases where you habitually reach for one.
Over-dependent
import axios from 'axios';
import yaml from 'js-yaml';
import _ from 'lodash';
import moment from 'moment';
import { v4 as uuidv4 } from 'uuid';
// 5 dependencies + their transitive tree
// (moment alone: 2 vulnerabilities as of 2026)
Minimal
import { get } from 'node:https';
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
// Zero external dependencies
// Maintenance grade: A by default
The dependency audit is one of SkillAudit's six scoring axes precisely because it has such a strong correlation with overall security posture. Servers with zero external dependencies score A on Maintenance by default (no CVEs possible, no abandoned packages, no version drift). Servers with ten-plus transitive dependencies routinely have one or more advisories active at any given time through no fault of the author.
When you must add a dependency — a database driver, a vendor SDK — prefer packages maintained by the vendor themselves, pin to an exact version, and add a weekly npm audit job to your CI. Dependencies you cannot remove should at least be monitored.
Principle 3 — per-tool credential isolation
A server with five tools that all use the same database connection string has a 5× blast radius compared to a server where each tool gets only the permissions it needs. Per-tool credential isolation is the principle of least privilege applied at the tool boundary.
In practice, this means each tool handler receives (or constructs) a credential scoped to exactly the resources that tool is allowed to access:
// Bad: one shared credential for all tools
const DB_CONN = new DatabaseClient(process.env.DATABASE_URL);
// DATABASE_URL has read+write on all tables
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "get_user_profile") {
// Read-only operation, but uses a credential with write access
return DB_CONN.query("SELECT * FROM users WHERE id = ?", [id]);
}
if (request.params.name === "send_notification") {
// Write operation, same credential — but also has read access to all tables
return DB_CONN.query("INSERT INTO notifications ...", [...]);
}
});
// Good: per-tool credential scoping
const USER_READER = new DatabaseClient({
url: process.env.DB_URL,
roles: ["user_reader"], // SELECT on users table only
});
const NOTIF_WRITER = new DatabaseClient({
url: process.env.DB_URL,
roles: ["notification_writer"], // INSERT on notifications table only
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "get_user_profile") {
return USER_READER.query("SELECT * FROM users WHERE id = ?", [id]);
}
if (request.params.name === "send_notification") {
return NOTIF_WRITER.query("INSERT INTO notifications ...", [...]);
}
});
Most database systems support role-based access control natively. PostgreSQL roles, MongoDB collection-level permissions, and AWS IAM policies at the resource level all enable this pattern. For external APIs, use separate API keys with the minimum required scope for each tool — many vendor platforms (Stripe, Twilio, GitHub) support this directly. If a vendor only issues one credential with full access, wrap it in an internal proxy that enforces tool-level scoping at the proxy layer.
Why this matters for LLM-assisted attacks: When an LLM is orchestrating tool calls, it may be manipulated by prompt injection in tool responses into calling tools it was not supposed to call, with arguments it was not supposed to use. Per-tool credential isolation ensures that even if the orchestration layer is compromised, the damage is bounded to the permissions of the manipulated tool.
Principle 4 — no shell invocation
Shell injection is the most reliably exploitable vulnerability class in MCP servers. It is also one of the easiest to avoid entirely: never call a shell. Use native language APIs or typed argument arrays instead.
The danger with child_process.exec() is that it passes a string to /bin/sh for interpretation, and shell interpretation means that any special character in the arguments (;, |, $, backtick) can introduce additional shell commands. An LLM orchestrator can be manipulated into providing arguments containing these characters — either by a malicious tool description or by a prompt injection in a prior tool's response.
Shell injection surface
// exec() passes string to /bin/sh
// argument injection → arbitrary command execution
const { exec } = require('child_process');
async function convertImage(inputPath, format) {
return new Promise((resolve, reject) => {
// Attacker controls format: "png; rm -rf /"
exec(`convert ${inputPath} output.${format}`,
(err, stdout) => {
if (err) reject(err);
else resolve(stdout);
});
});
}
Typed argument array — no shell
// spawn() with shell:false and an array
// special characters are passed as literal data
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
async function convertImage(inputPath, format) {
// format is a literal string argument, not interpolated
const safeFormat = ['png','jpg','webp'].includes(format)
? format : 'png'; // allowlist before exec
return execFileAsync('convert',
[inputPath, `output.${safeFormat}`],
{ shell: false }); // never interpret arguments as shell
}
The rule is: if you find yourself constructing a string that will be interpreted by a shell, stop and find the native API. For file system operations, use node:fs. For HTTP requests, use node:https. For image processing, use a native library. For git operations, use isomorphic-git or the GitHub API. For the rare cases where you genuinely need to exec an external binary, use execFile() with shell: false and a typed argument array — and validate every argument against an allowlist before passing it.
This principle also extends to template engines: never pass tool arguments into a template that will be evaluated (eval(), Jinja2 from_string(), Handlebars compile()). See the SSTI guide for the full exploit chains that arise when this constraint is violated.
Principle 5 — declarative permission manifest
A declarative permission manifest is a static file that lists everything your MCP server is allowed to access: which file system paths, which external hostnames, which environment variables, which ports. Anything not on the list should produce a runtime error, not a successful call.
This serves two purposes. First, it provides a machine-readable description of what the server does before anyone has to read the code — a reviewer, a CI gating system, or SkillAudit's static analysis can read the manifest and immediately understand the blast radius. Second, it makes enforcement possible: a runtime that validates calls against the manifest turns accidental over-access into a loud error rather than a silent capability.
{
"name": "my-mcp-server",
"version": "1.0.0",
"permissions": {
"filesystem": {
"read": ["/home/user/documents/**", "/tmp/mcp-work/**"],
"write": ["/tmp/mcp-work/**"]
},
"network": {
"outbound": ["api.github.com:443", "api.openai.com:443"]
},
"environment": {
"read": ["GITHUB_TOKEN", "OPENAI_API_KEY"]
},
"tools": [
{
"name": "read_document",
"credential": "none",
"maxInputBytes": 1048576
},
{
"name": "call_github_api",
"credential": "GITHUB_TOKEN",
"allowedEndpoints": ["repos/*", "users/*"]
}
]
}
}
At runtime, validate every file operation, outbound request, and environment variable access against the manifest. The implementation can be as simple as a middleware function that runs before every tool handler:
function validateFileAccess(path, mode, manifest) {
const patterns = manifest.permissions.filesystem[mode] || [];
const allowed = patterns.some(p => minimatch(path, p));
if (!allowed) {
throw new Error(`File access denied: ${path} not in ${mode} allowlist`);
}
}
function validateNetworkAccess(hostname, port, manifest) {
const allowed = manifest.permissions.network.outbound.includes(`${hostname}:${port}`);
if (!allowed) {
throw new Error(`Network access denied: ${hostname}:${port} not in outbound allowlist`);
}
}
When SkillAudit's static analysis reads a manifest, it can verify in 60 seconds whether the code actually matches what the manifest declares — whether there are outbound calls to hostnames not in the list, or reads from paths outside the declared set. The manifest makes the gap between declared and actual behavior visible.
Principle 6 — immutable append-only audit log
An audit log is only meaningful if it cannot be modified by the system it is auditing. An MCP server that writes its own audit log to a file it can also delete or overwrite provides weak evidence — a compromised server could cover its tracks. The minimal-footprint pattern externalizes the audit log to a sink the server can only append to.
In practice, "immutable" in an MCP server context means: append-only writes, no delete, no overwrite, and the log sink is outside the server's own file system permissions scope if possible.
import { createWriteStream } from 'node:fs';
import { open, O_WRONLY | O_CREAT | O_APPEND } from 'node:fs/promises';
// Open the log file in append-only mode
// The server never opens it with O_TRUNC or O_RDWR
const logFd = await open('/var/log/mcp-audit.ndjson',
O_WRONLY | O_CREAT | O_APPEND);
const logStream = createWriteStream(null, { fd: logFd });
function auditLog(entry) {
const record = JSON.stringify({
ts: new Date().toISOString(),
...entry,
}) + '\n';
logStream.write(record); // append-only; no delete or seek
}
// Wrap every tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const start = Date.now();
auditLog({ event: 'tool_call', tool: request.params.name,
args: request.params.arguments });
try {
const result = await dispatch(request);
auditLog({ event: 'tool_result', tool: request.params.name,
durationMs: Date.now() - start, success: true });
return result;
} catch (err) {
auditLog({ event: 'tool_error', tool: request.params.name,
durationMs: Date.now() - start, error: err.message });
throw err;
}
});
For higher-assurance environments, forward the log stream to a write-once sink: an S3 bucket with Object Lock in COMPLIANCE mode, a CloudWatch log group with immutability enabled, or a syslog server running on a separate host. The server sends records but cannot read or modify the sink's stored data.
The audit log also serves as a forensics resource when something goes wrong. Without it, a security incident in an MCP server is effectively uninvestigable — you know a tool was called, but not with what arguments, not at what time, not in what sequence with other tool calls. With a complete append-only log, you can reconstruct the full attack chain from the first malicious tool argument to the final exfiltration attempt.
Putting it together — the minimal-footprint template
A server built on these six principles looks like this at the structural level:
// index.ts — minimal-footprint MCP server template
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema }
from "@modelcontextprotocol/sdk/types.js";
// Standard library only — no external imports beyond the SDK
import { readFile } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
// Load the declarative permission manifest
const manifest = JSON.parse(
await readFile(join(import.meta.dirname, 'permissions.json'), 'utf8')
);
// Open audit log in append-only mode
const logStream = createWriteStream('/var/log/mcp-audit.ndjson', { flags: 'a' });
function audit(entry) {
logStream.write(JSON.stringify({ ts: new Date().toISOString(),
session: SESSION_ID, ...entry }) + '\n');
}
const SESSION_ID = randomUUID();
const server = new Server(
{ name: manifest.name, version: manifest.version },
{ capabilities: { tools: {} } }
);
// Register tools with per-tool credential scoping
const tools = await loadTools(manifest); // returns Map
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [...tools.values()].map(t => t.schema),
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const tool = tools.get(name);
if (!tool) throw new Error(`Unknown tool: ${name}`);
// Validate against manifest before dispatch
validatePermissions(name, args, manifest);
audit({ event: 'call', tool: name, args });
try {
const result = await tool.handler(args, tool.credential);
audit({ event: 'ok', tool: name });
return result;
} catch (err) {
audit({ event: 'err', tool: name, msg: err.message });
throw err;
}
});
// Connect via stdio — no network port
const transport = new StdioServerTransport();
await server.connect(transport);
audit({ event: 'started' });
This template is around 60 lines. It does not include business logic, but it includes all six security-critical structural decisions. Any tool handler added to this template inherits the full security posture: stdio isolation, zero external dependencies (by policy), per-tool credential injection, no shell surface, manifest-validated access, and an append-only audit trail for every call.
What this scores on SkillAudit
Running this template through SkillAudit's six scoring axes:
- Security: A — no SSRF surface (stdio only), no shell invocation, parameterized queries by default, no credential leakage paths visible in static analysis
- Permissions hygiene: A — declarative manifest with least-privilege scoping; no ambient authority; per-tool credential isolation
- Credential exposure: A — credentials scoped to tool handlers, not shared globals; manifest declares which env vars are read; audit log does not echo credential values
- Maintenance: A — zero external dependencies → zero CVE exposure; no deprecated APIs; no abandoned package risk
- Client compatibility: A — stdio transport works with every MCP client (Claude Code, Cursor, Windsurf, Codex); no SSE/HTTP transport quirks
- Documentation completeness: depends on what you write — the manifest itself is machine-readable documentation for security reviewers
Servers built on this architecture regularly score A overall on SkillAudit from the first scan, before any remediation. Compare that to the average community MCP server, which starts at C and requires two to three iterations before reaching A.
The one exception: when HTTP transport is unavoidable
Some legitimate use cases cannot use stdio: a server hosted for multiple clients simultaneously, a server embedded in a web application, or a server that needs to receive webhook events. If your use case genuinely requires HTTP transport, the minimal-footprint principles still apply — they just become harder to enforce because the network layer introduces additional attack surfaces.
For HTTP-transport servers, the minimal-footprint additions are: TLS only (no plaintext HTTP), strict CORS allowlist (not *), statically configured base URL (never trust the Host header — see the Host header injection guide), rate limiting per client, and request size limits. These do not appear in a stdio server because they are meaningless without a network layer.
The CI/CD security pipeline guide covers how to gate HTTP-transport server deployments on a SkillAudit score before they reach production — so that architectural regressions (a new dependency added, a shell invocation introduced) are caught before they reach users.
Start minimal; add only what you need
The minimal-footprint architecture is not about artificial constraint — it is about recognizing that every capability you add to an MCP server is also a risk surface you are asking users to accept when they claude mcp add your server. The MCP security landscape in 2026 is one where 36.7% of community servers have SSRF vulnerabilities and 43% have unsafe command-exec paths, largely because they were built by adding capabilities first and thinking about security second.
Building from the minimum and adding only what the use case requires is a discipline that produces smaller attack surfaces, lower maintenance burden, and faster security reviews. The six principles above are not a checklist to retrofit — they are decisions to make before the first git commit. Once made, they cost almost nothing to maintain.
If you have an existing server that was built without these constraints, the MCP server security checklist and the SkillAudit report guide will help you identify which architectural changes to prioritize first. The permission scope patterns post covers the per-tool credential isolation principle in more depth, with examples for PostgreSQL, MongoDB, and AWS IAM.
See how your MCP server measures up
Paste a GitHub URL and get a graded report across all six security axes — including whether your architecture is minimal-footprint or has hidden blast-radius problems.
Run a free audit