agents #mcp#security#authentication#sandboxing#docker#fastmcp#production

Securing Local and Remote MCP Servers: Token Auth, Sandboxing, and Least Privilege

S

S L Manikanta

Sep 13, 2026 • 7 min read

bolt Key Takeaways

  • MCP servers run with the same OS permissions as the host process — any tool that reads files or runs commands is a potential privilege escalation vector.
  • For remote MCP servers, always require Bearer token authentication on the SSE endpoint. Never expose an unauthenticated MCP server over a network.
  • Sandbox local MCP servers in Docker with read-only mounts and a non-root user to limit filesystem blast radius.
  • Implement a path allowlist in every file-access tool — validate the resolved path starts with your approved root before opening or writing any file.
✉ Newsletter

Want to build production-ready AI?

Subscribe to StackMindset to receive actionable systems engineering checklists and code walkthroughs. No spam, only technical insights.

[!IMPORTANT] The core threat model: Your MCP server tools run with the OS permissions of the host process. If an LLM is manipulated (via prompt injection or a misconfigured system prompt) into calling a tool with a malicious argument, the consequences happen at the OS level — not inside a sandbox.

The minimum viable security posture for any production MCP server:

  1. Run as non-root
  2. Path allowlist on all file-access tools
  3. Token auth on all network-exposed SSE endpoints
  4. Rate limit tool calls per session

MCP’s power is also its risk surface. A tool that reads files, executes SQL, or calls external APIs operates at OS-level permissions. This guide covers the concrete security controls that go between “it works” and “it’s safe to deploy.”


Environment

PackageVersion
fastmcp2.2.0
fastapi0.115+
python-jose3.3.0
docker26+
Python3.11+

1. Threat Model

graph TD
    LLM[LLM / Claude Code]
    MCP[MCP Server]
    Tools[Tools: read_file, run_sql, fetch_url]
    OS[OS: Filesystem, Network, Processes]

    LLM -->|tool call| MCP
    MCP -->|invoke| Tools
    Tools -->|syscalls| OS

    Attacker1[Prompt Injection\nin fetched content]
    Attacker2[Unauthorized\nSSE client]
    Attacker3[Path traversal\n in tool args]

    Attacker1 -.manipulates.-> LLM
    Attacker2 -.connects to.-> MCP
    Attacker3 -.exploits.-> Tools

Three attack surfaces to harden:

  1. Prompt injection — malicious content in tool outputs manipulates the LLM
  2. Unauthorized access — unauthenticated clients connecting to your SSE endpoint
  3. Tool argument injection — path traversal, SQL injection, or command injection via tool args

2. Path Allowlist for File-Access Tools

Every tool that reads or writes files must validate the resolved absolute path against an approved root directory before opening anything.

import pathlib
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError

mcp = FastMCP("secure-server")

ALLOWED_ROOT = pathlib.Path("/workspace/data").resolve()

def _safe_path(user_path: str) -> pathlib.Path:
    """Resolve user-supplied path and verify it's within the allowed root."""
    try:
        target = (ALLOWED_ROOT / user_path).resolve()
    except (ValueError, OSError) as e:
        raise ToolError(f"Invalid path: {e}") from e

    # The critical check — prevent directory traversal
    if not str(target).startswith(str(ALLOWED_ROOT)):
        raise ToolError(
            f"Access denied: path resolves outside allowed root. "
            f"Allowed root: {ALLOWED_ROOT}"
        )
    return target

@mcp.tool()
def read_file(relative_path: str) -> str:
    """Read a file from the data workspace. Path must be relative to /workspace/data."""
    target = _safe_path(relative_path)

    if not target.is_file():
        raise ToolError(f"File not found: {relative_path}")

    # Size limit prevents memory exhaustion
    size_bytes = target.stat().st_size
    if size_bytes > 5 * 1024 * 1024:  # 5 MB
        raise ToolError(f"File too large ({size_bytes // 1024} KB). Maximum is 5 MB.")

    return target.read_text(encoding="utf-8", errors="replace")

@mcp.tool()
def write_file(relative_path: str, content: str) -> str:
    """Write content to a file in the data workspace."""
    target = _safe_path(relative_path)
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(content, encoding="utf-8")
    return f"Written {len(content)} characters to {relative_path}"

The _safe_path() function resolves symlinks via pathlib.Path.resolve() before comparing against the allowed root. Without resolve(), a symlink outside the allowed directory passes a naive prefix check.


3. Token Authentication on SSE Endpoints

For remote MCP servers (SSE transport), mount FastMCP inside FastAPI and add bearer token validation:

import os
import logging
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastmcp import FastMCP
import jwt  # python-jose

logger = logging.getLogger(__name__)
security = HTTPBearer()

mcp = FastMCP("authenticated-server")

# In production: load from env and rotate regularly
JWT_SECRET = os.environ["MCP_JWT_SECRET"]
JWT_ALGORITHM = "HS256"

def verify_token(
    credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
    token = credentials.credentials
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
        return payload
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token expired",
            headers={"WWW-Authenticate": "Bearer"},
        )
    except jwt.JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token",
            headers={"WWW-Authenticate": "Bearer"},
        )

# Build the FastAPI app
app = FastAPI()

# Mount the MCP SSE endpoint with auth dependency
mcp_app = mcp.get_asgi_app()

@app.get("/sse", dependencies=[Depends(verify_token)])
async def sse_endpoint():
    """SSE endpoint — token verified by dependency."""
    pass

# Mount MCP app under /mcp prefix, with the auth-protected /sse route
app.mount("/mcp", mcp_app)

For OAuth-based auth (e.g., verifying tokens issued by your OAuth provider):

import httpx

OAUTH_INTROSPECT_URL = os.environ["OAUTH_INTROSPECT_URL"]
OAUTH_CLIENT_ID = os.environ["OAUTH_CLIENT_ID"]
OAUTH_CLIENT_SECRET = os.environ["OAUTH_CLIENT_SECRET"]

async def verify_oauth_token(
    credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
    async with httpx.AsyncClient() as client:
        response = await client.post(
            OAUTH_INTROSPECT_URL,
            data={"token": credentials.credentials},
            auth=(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET),
        )
    if response.status_code != 200 or not response.json().get("active"):
        raise HTTPException(status_code=401, detail="Invalid or expired token")
    return response.json()

4. Docker Sandboxing for Local Servers

Even for local stdio servers, running in Docker provides a strong filesystem isolation boundary:

# Dockerfile
FROM python:3.11-slim

# Create a non-root user
RUN useradd --system --uid 1001 --no-create-home mcp-user

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY server.py .

# Switch to non-root user before running
USER mcp-user

CMD ["python", "server.py"]

Register the Docker-based server in Claude Code config:

{
  "mcpServers": {
    "secure-docs-server": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "--read-only",
        "--tmpfs", "/tmp:size=50m",
        "--mount", "type=bind,source=/workspace/data,target=/workspace/data,readonly",
        "--network", "none",
        "--memory", "512m",
        "--cpus", "1.0",
        "your-mcp-server:latest"
      ]
    }
  }
}

Key Docker flags:

  • --read-only: root filesystem is read-only
  • --tmpfs /tmp: provides a writable temp directory in RAM only
  • --network none: blocks all outbound network access
  • --memory 512m: prevents memory-based DoS
  • --mount readonly: data directory is visible but not writable

5. Rate Limiting Tool Calls

Prevent runaway tool loops (from LLM bugs or prompt injection) with a per-session rate limiter:

import time
from collections import defaultdict
from threading import Lock
from fastmcp.exceptions import ToolError

class RateLimiter:
    def __init__(self, max_calls: int, window_seconds: float):
        self.max_calls = max_calls
        self.window_seconds = window_seconds
        self._calls: dict[str, list[float]] = defaultdict(list)
        self._lock = Lock()

    def check(self, session_id: str):
        now = time.monotonic()
        with self._lock:
            calls = self._calls[session_id]
            # Remove calls outside the window
            cutoff = now - self.window_seconds
            self._calls[session_id] = [t for t in calls if t > cutoff]
            if len(self._calls[session_id]) >= self.max_calls:
                raise ToolError(
                    f"Rate limit exceeded: maximum {self.max_calls} tool calls "
                    f"per {self.window_seconds}s window per session."
                )
            self._calls[session_id].append(now)

# 60 tool calls per minute per session
rate_limiter = RateLimiter(max_calls=60, window_seconds=60.0)

@mcp.tool()
def expensive_operation(session_id: str, data: str) -> str:
    """Perform an expensive operation. Rate-limited to 60 calls/minute."""
    rate_limiter.check(session_id)
    # ... actual work
    return "completed"

6. Prompt Injection Defenses

Attack VectorDefense
Injected instructions in fetched web contentSanitize fetched HTML to plain text before returning; never return raw HTML
SQL result rows containing LLM instructionsWrap database results in a structured format: {"rows": [...]} — avoid returning raw strings
File content with embedded system prompt textMark tool output provenance clearly: prefix with [FILE CONTENT] so the LLM understands it’s data, not instructions
Tool descriptions that trigger other toolsReview all tool descriptions for ambiguous phrasing; avoid “if X then call Y” patterns in descriptions

7. Tool Allowlist Per Session Role

Control which tools are available based on the authenticated user’s role:

ROLE_TOOLS = {
    "reader": {"read_file", "search_docs", "list_directory"},
    "writer": {"read_file", "write_file", "search_docs", "list_directory"},
    "admin": None,  # None = all tools
}

def get_allowed_tools(role: str) -> set[str] | None:
    return ROLE_TOOLS.get(role, set())

# In the SSE handler, filter the tools list based on the verified token's role

Next Steps

For building the MCP server itself before hardening it, see Building High-Throughput Production MCP Servers with Python FastMCP and stdio.

The PriviPaste project demonstrates a local PII redaction MCP layer — an example of least-privilege tool design where the server only receives and returns sanitized content.

For securing LangGraph agents that consume MCP tool outputs, see AI Agent Security Best Practices.

✉ Newsletter

Want to build production-ready AI?

Subscribe to StackMindset to receive actionable systems engineering checklists and code walkthroughs. No spam, only technical insights.

S

Written by S L Manikanta

AI Engineer specializing in agentic workflows, multi-step LLM validation pipelines, and secure cloud environments. Sharing practical lessons from building software.

Related Articles

agents
What is an Agentic Loop? Claude Agent SDK Complete Reference (2026)

A complete technical reference on the Agentic Loop architecture, exploring the Claude Agent SDK lifecycle, context compaction, and how to build autonomous while-loops.

agents
What is Model Context Protocol (MCP)? Complete Technical Reference (2026)

A definitive guide to the Model Context Protocol (MCP). Learn how Anthropic's open standard enables AI assistants to securely connect to external tools, databases, and APIs.

agents
Building High-Throughput Production MCP Servers with Python FastMCP and stdio

Build a production-ready MCP server with FastMCP over stdio transport. Covers tool registration, schema validation, error handling, concurrency limits, and Claude Code integration.