Skip to main content
MCP

Local-Only MCP Servers for Sensitive Data: Air-Gapped Agent Workflows

Ravinder··10 min read
MCPSecurityLocal-FirstAI
Share:
Local-Only MCP Servers for Sensitive Data: Air-Gapped Agent Workflows

Every MCP tutorial assumes a networked deployment. SSE transport, a port to bind, TLS certificates, a cloud function or container image. This model works for most integrations, but it is categorically wrong for a class of workload that is growing quickly: AI agents that need to touch data that cannot leave the machine.

Medical records under HIPAA. Financial models under regulatory hold. Source code in a classified environment. Customer PII that is contractually restricted to a specific jurisdiction. Proprietary training data that, if exfiltrated, destroys competitive advantage. For all of these, the network is not the answer — it is the problem.

The MCP protocol supports a stdio transport that is designed exactly for this case. An MCP server running over stdio never binds a port, never makes a network call (unless you write one explicitly), and runs as a child process of the client with the same access controls as any local process. Combined with OS-level sandboxing, careful secret handling, and a local audit log, you can build agent workflows that are genuinely air-gapped.

This post is a practical guide to building, securing, and auditing a local-only MCP server.

Why stdio Is Not a Compromise

The MCP specification defines two transports: HTTP with Server-Sent Events, and stdio. The HTTP transport is what you use for remote servers. The stdio transport is what you use when the server and client run on the same machine — or at least in the same trust boundary.

With stdio transport, the client spawns the server as a child process and communicates over stdin/stdout. There is no port. There is no TLS handshake. There is no network interface. The communication channel is a pair of kernel pipes that are never exposed outside the process hierarchy.

graph LR subgraph "Same Machine / Same Trust Boundary" C[MCP Client\ne.g. Claude Desktop] -->|stdin| S[MCP Server Process] S -->|stdout| C S --> FS[Local Filesystem] S --> DB[(Local Database)] S -->|audit log| AL[Append-Only Audit File] end C <-->|LLM API calls| NET[Internet / LLM Provider] NET -.->|tool results never leave| NEVER[Never Transmitted]

The LLM provider does still receive the tool result — because the client sends it as context in the next request. This is the critical boundary to understand: the MCP server never touches the network, but the client does. If you need the data to never reach the LLM at all, that is a different architecture (embed-and-search locally). If you need the data to be used by an LLM but processed and stored locally with no persistent copy at the provider, the stdio MCP pattern is correct.

Setting Up stdio Transport

The server implementation is straightforward. Replace SSEServerTransport with StdioServerTransport:

// src/server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
 
const server = new Server(
  {
    name: "local-sensitive-data-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
      resources: {},
    },
  }
);
 
// Register tools here...
 
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
 
  // Never write to stdout after this point — it is owned by the MCP transport.
  // Use stderr for any diagnostic output.
  process.stderr.write("MCP server started on stdio\n");
}
 
main().catch((err) => {
  process.stderr.write(`Fatal error: ${err}\n`);
  process.exit(1);
});

The most common mistake when building stdio MCP servers is accidentally writing to stdout — a console.log call in a handler, for example. Every byte written to stdout is interpreted as a JSON-RPC frame by the client. Replace all console.log with console.error (which writes to stderr) before going to production.

// src/logger.ts — safe logging for stdio servers
import { createWriteStream } from "node:fs";
import { join } from "node:path";
import pino from "pino";
 
const logDir = process.env.MCP_LOG_DIR ?? join(process.env.HOME ?? "/tmp", ".mcp-logs");
 
export const logger = pino(
  {
    level: process.env.LOG_LEVEL ?? "info",
    serializers: {
      err: pino.stdSerializers.err,
    },
  },
  // Write to a file, never to stdout
  createWriteStream(join(logDir, `${process.env.MCP_SERVER_NAME ?? "server"}.log`), { flags: "a" })
);

Process Sandboxing on macOS and Linux

A stdio MCP server runs with the privileges of the spawning user. That is usually too permissive. You want to constrain what the server process can do — which files it can read, which syscalls it can make, whether it can spawn child processes or open network connections.

On macOS, use the sandbox-exec tool with a custom profile:

;; mcp-server.sb — macOS sandbox profile
(version 1)
(deny default)
 
;; Allow reading from the specific data directory only
(allow file-read* (subpath "/Users/youruser/sensitive-data"))
 
;; Allow writing to the audit log directory only
(allow file-write* (subpath "/Users/youruser/.mcp-audit"))
 
;; Allow reading system libraries needed by Node.js
(allow file-read* (subpath "/usr/lib"))
(allow file-read* (subpath "/usr/local/lib"))
(allow file-read* (subpath "/private/var/db/timezone"))
 
;; Allow stdio communication (essential for the transport)
(allow file-read* file-write*
  (literal "/dev/stdin")
  (literal "/dev/stdout")
  (literal "/dev/stderr")
  (literal "/dev/null"))
 
;; Allow process operations needed by Node.js
(allow process-exec (subpath "/usr/local/bin/node"))
(allow sysctl-read)
(allow mach-lookup)
 
;; Explicitly deny network access
(deny network*)

Launch the server with this profile:

sandbox-exec -f mcp-server.sb node dist/server.js

On Linux, use systemd-run with namespace isolation, or a minimal seccomp profile with bubblewrap:

bwrap \
  --ro-bind /usr /usr \
  --ro-bind /lib /lib \
  --ro-bind /lib64 /lib64 \
  --ro-bind /sensitive-data /sensitive-data \
  --bind /home/user/.mcp-audit /home/user/.mcp-audit \
  --unshare-net \
  --unshare-pid \
  --die-with-parent \
  node dist/server.js

The --unshare-net flag is the critical one — it puts the server process in a network namespace with no interfaces, making outbound connections impossible at the kernel level regardless of what the application code does.

Secret Handling — Not in Environment Variables

Environment variables are accessible to any subprocess and show up in process listings on some systems. For a local MCP server handling sensitive data, use a local secret store instead.

On macOS, the Keychain is the right answer:

// src/secrets/keychain.ts
import { execFileSync } from "node:child_process";
 
export function getSecret(service: string, account: string): string {
  try {
    const result = execFileSync("security", [
      "find-generic-password",
      "-s", service,
      "-a", account,
      "-w", // print password only
    ], { encoding: "utf-8" });
    return result.trim();
  } catch {
    throw new Error(`Secret not found: ${service}/${account}. Run: security add-generic-password -s "${service}" -a "${account}" -w`);
  }
}
 
// Usage
const dbPassword = getSecret("mcp-local-server", "database-password");

On Linux, use secret-tool (GNOME Keyring) or pass (GPG-encrypted password store):

// src/secrets/secretstore.ts
import { execFileSync } from "node:child_process";
 
export function getSecretLinux(attribute: string, value: string): string {
  const result = execFileSync("secret-tool", [
    "lookup", attribute, value
  ], { encoding: "utf-8" });
  return result.trim();
}

Never hard-code secrets. Never put them in the MCP server's configuration file if that file lives in a version-controlled directory. The pattern is: secret lives in the OS keystore, MCP server fetches it at startup, secret is held in memory only for the duration of the process.

Audit Logging — The Non-Negotiable Requirement

For any MCP server touching regulated or sensitive data, every tool call must be logged to an append-only audit trail. The audit log is distinct from the application log: it is tamper-evident, includes the full input arguments, includes the caller identity, and is never automatically rotated or deleted by the application.

// src/audit.ts
import { createWriteStream } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
 
interface AuditEvent {
  timestamp: string;
  eventType: "tool_call" | "tool_result" | "server_start" | "server_stop";
  toolName?: string;
  arguments?: unknown;
  resultSummary?: string;
  agentRunId?: string;
  hash?: string; // hash of previous entry for chain integrity
}
 
class AuditLogger {
  private stream: ReturnType<typeof createWriteStream>;
  private lastHash = "genesis";
 
  constructor(logDir: string, serverName: string) {
    const filename = join(logDir, `audit-${serverName}-${new Date().toISOString().slice(0, 10)}.ndjson`);
    this.stream = createWriteStream(filename, { flags: "a" });
  }
 
  log(event: Omit<AuditEvent, "hash" | "timestamp">): void {
    const entry: AuditEvent = {
      ...event,
      timestamp: new Date().toISOString(),
      hash: createHash("sha256")
        .update(this.lastHash + JSON.stringify(event))
        .digest("hex"),
    };
    this.lastHash = entry.hash!;
 
    // Write is synchronous to ensure no audit events are lost on crash
    const line = JSON.stringify(entry) + "\n";
    this.stream.write(line);
  }
}
 
export const audit = new AuditLogger(
  process.env.MCP_AUDIT_DIR ?? join(process.env.HOME ?? "/tmp", ".mcp-audit"),
  process.env.MCP_SERVER_NAME ?? "server"
);

Use this in every tool handler:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  audit.log({
    eventType: "tool_call",
    toolName: request.params.name,
    // Log arguments but redact any field named "password", "secret", "token"
    arguments: redactSensitiveFields(request.params.arguments),
    agentRunId: (request.params._meta as any)?.agentRunId,
  });
 
  const result = await dispatchTool(request.params.name, request.params.arguments);
 
  audit.log({
    eventType: "tool_result",
    toolName: request.params.name,
    resultSummary: summarizeResult(result),
    agentRunId: (request.params._meta as any)?.agentRunId,
  });
 
  return result;
});

The chain hash is not a substitute for a real tamper-evident log store (that requires external verification), but it lets you detect whether entries have been deleted or reordered after the fact — which covers the most common tampering scenarios in a local environment.

When Cloud Is the Wrong Answer

The choice to build a local-only MCP server is not always about compliance. There are pragmatic cases where local is simply better:

Latency-sensitive workflows. If your agent needs to call a tool 20 times per interaction and each call involves large file reads, the round-trip to a remote server adds up. A local server over stdio has sub-millisecond transport overhead.

Large file contexts. A tool that reads a 200MB log file and returns a summary cannot practically be a remote service — the bandwidth cost of sending that file to a remote server is prohibitive. Local means the file stays on disk and the server reads it directly.

Development and testing. A local stdio server is trivial to start, stop, and inspect. No port conflicts, no TLS setup, no authentication tokens to manage. The development feedback loop is instant.

Corporate environments with outbound filtering. Many enterprise environments filter outbound connections at the network layer. A local stdio server bypasses this entirely — not to evade security, but because it has no outbound connections to filter.

The boundary where you should choose cloud: when the data source itself is remote (database in AWS, files in S3), when multiple users need simultaneous access, or when you need centralized access control and audit. For those cases, build a remote MCP server with proper authentication. For everything else, consider whether stdio is the simpler and more secure choice.

Key Takeaways

  • The stdio transport is not a fallback — it is the architecturally correct choice when the server and client share a trust boundary and the data must not traverse a network.
  • Never write to stdout in a stdio MCP server; all diagnostic output goes to stderr or a file. A stray console.log corrupts the JSON-RPC stream and crashes the client.
  • OS-level sandboxing (sandbox-exec on macOS, bwrap on Linux) should deny network access at the kernel level, not just rely on application-level restrictions.
  • Secrets belong in the OS keystore (macOS Keychain, GNOME Keyring, pass), not in environment variables or configuration files.
  • Every tool call must write to an append-only audit log with a chain hash; the log is the compliance artifact, not just the application log.
  • Local-only MCP servers are also the right choice for latency-sensitive workflows, large file contexts, development environments, and corporate networks with outbound filtering.