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:

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