春江暮客

春江暮客的个人学习分享网站

Secure Python MCP Server: Add Boundaries to AI Tool Calls

2026-06-01 Technology
Secure Python MCP Server: Add Boundaries to AI Tool Calls

It is easy to build a first MCP Server. Once you connect it to an AI client, a second problem appears quickly: the model can call tools, but it may also send bad arguments, out-of-scope paths, or unsafe commands to those tools.

This article has one practical goal: build a safer local MCP Server with Python and FastMCP. It does not try to implement a complex permission system. It starts with four useful controls:

  1. Limit tools to the project directory
  2. Read only allowlisted file suffixes
  3. Limit single-read file size
  4. Log every tool call for auditing

Method 1: Define the Security Boundary First

Do not start from “let the AI do everything.” Start by writing down what it must not do.

The example tools in this article only allow:

  1. Listing a small number of text-like files inside the project
  2. Reading safe-suffix files inside the project
  3. Parsing top-level keys from a JSON config file inside the project

They do not allow:

  1. Using ../ to escape the project directory
  2. Reading large files
  3. Running arbitrary shell commands
  4. Returning unlimited content

This boundary is simple, but it covers many local development, documentation, and config-checking workflows.

Method 2: Create a Safer MCP Server

1. Prepare the environment

Use uv to create an isolated project:

mkdir secure-mcp-tools
cd secure-mcp-tools
uv init
uv add "mcp[cli]"

Create test files:

printf '# Demo\n\nhello secure MCP\n' > README.md
printf '{"name":"demo","debug":true}\n' > config.json

Verify that the MCP development command is available:

uv run mcp --help

If you can see the dev subcommand, the environment is ready.

2. Write server.py

Create server.py:

from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from mcp.server.fastmcp import FastMCP


mcp = FastMCP("Secure Local Tools")

PROJECT_ROOT = Path.cwd().resolve()
AUDIT_LOG = PROJECT_ROOT / "mcp-audit.log"
ALLOWED_SUFFIXES = {".md", ".txt", ".json", ".yaml", ".yml", ".py"}
MAX_FILE_BYTES = 64 * 1024
MAX_LIST_RESULTS = 50


def audit(event: str, **fields: Any) -> None:
    record = {
        "time": datetime.now(timezone.utc).isoformat(),
        "event": event,
        **fields,
    }
    with AUDIT_LOG.open("a", encoding="utf-8") as fp:
        fp.write(json.dumps(record, ensure_ascii=False) + "\n")


def resolve_project_path(path: str) -> Path:
    if not path or path.startswith(("/", "~")) or "\x00" in path:
        raise ValueError("path must be a relative project path")

    target = (PROJECT_ROOT / path).resolve()

    try:
        target.relative_to(PROJECT_ROOT)
    except ValueError as exc:
        raise ValueError("path must stay inside the project") from exc

    return target


def check_readable_file(path: str) -> Path:
    target = resolve_project_path(path)

    if not target.exists() or not target.is_file():
        raise ValueError("file does not exist")

    if target.suffix.lower() not in ALLOWED_SUFFIXES:
        raise ValueError(f"suffix is not allowed: {target.suffix}")

    size = target.stat().st_size
    if size > MAX_FILE_BYTES:
        raise ValueError(f"file is too large: {size} bytes")

    return target


@mcp.tool()
def list_allowed_files(directory: str = ".") -> list[str]:
    """List safe text-like files under the project directory."""
    target = resolve_project_path(directory)

    if not target.exists() or not target.is_dir():
        raise ValueError("directory does not exist")

    results: list[str] = []
    for item in sorted(target.rglob("*")):
        if item.is_file() and item.suffix.lower() in ALLOWED_SUFFIXES:
            results.append(str(item.relative_to(PROJECT_ROOT)))
        if len(results) >= MAX_LIST_RESULTS:
            break

    audit("list_allowed_files", directory=directory, count=len(results))
    return results


@mcp.tool()
def read_allowed_file(path: str) -> dict[str, Any]:
    """Read one small allowlisted file inside the project directory."""
    target = check_readable_file(path)
    content = target.read_text(encoding="utf-8")

    audit("read_allowed_file", path=path, bytes=target.stat().st_size)
    return {
        "path": str(target.relative_to(PROJECT_ROOT)),
        "bytes": target.stat().st_size,
        "content": content,
    }


@mcp.tool()
def summarize_json_keys(path: str) -> dict[str, Any]:
    """Return top-level keys from a small JSON file."""
    target = check_readable_file(path)

    if target.suffix.lower() != ".json":
        raise ValueError("path must point to a JSON file")

    data = json.loads(target.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError("JSON root must be an object")

    keys = sorted(str(key) for key in data.keys())
    audit("summarize_json_keys", path=path, keys=len(keys))
    return {
        "path": str(target.relative_to(PROJECT_ROOT)),
        "keys": keys,
    }


if __name__ == "__main__":
    mcp.run()

The important part is not the number of tools. The important part is that every tool has a clear boundary:

  1. resolve_project_path() prevents path escape
  2. ALLOWED_SUFFIXES controls readable file types
  3. MAX_FILE_BYTES limits returned content size
  4. audit() writes call records to mcp-audit.log

Method 3: Debug and Validate

Start MCP Inspector:

uv run mcp dev server.py

In Inspector, call list_allowed_files first:

{
  "directory": "."
}

You should see a result like this:

[
  "README.md",
  "config.json"
]

Then call read_allowed_file:

{
  "path": "README.md"
}

Expected response:

{
  "path": "README.md",
  "bytes": 24,
  "content": "# Demo\n\nhello secure MCP\n"
}

Test the JSON parsing tool:

{
  "path": "config.json"
}

Expected response:

{
  "path": "config.json",
  "keys": [
    "debug",
    "name"
  ]
}

Finally, check the audit log:

cat mcp-audit.log

The output should look like this:

{"time":"2026-06-01T01:05:30.123456+00:00","event":"list_allowed_files","directory":".","count":2}
{"time":"2026-06-01T01:05:41.123456+00:00","event":"read_allowed_file","path":"README.md","bytes":24}

Troubleshooting

Error 1: path must stay inside the project

If the client sends:

{
  "path": "../.ssh/id_rsa"
}

The server rejects the request. The fix is not to remove the boundary. Copy the needed file into the project directory, or design a stricter dedicated tool for a specific directory.

Error 2: suffix is not allowed

If you need to read .toml or .csv, confirm the business need first, then add the suffix to the allowlist:

ALLOWED_SUFFIXES = {".md", ".txt", ".json", ".yaml", ".yml", ".py", ".toml"}

Do not change this into “allow every suffix” for convenience.

Error 3: file is too large

Do not return large files to the model in full. A better pattern is to add a dedicated tool that returns the first N lines, matching lines, or a structured summary.

For example:

@mcp.tool()
def head_file(path: str, lines: int = 40) -> list[str]:
    target = check_readable_file(path)
    lines = max(1, min(lines, 200))
    return target.read_text(encoding="utf-8").splitlines()[:lines]

Error 4: You want MCP to run commands

Do not expose a tool like this directly:

import subprocess


@mcp.tool()
def run_command(command: str) -> str:
    return subprocess.check_output(command, shell=True, text=True)

If you really need command execution, use a fixed allowlist:

import subprocess


ALLOWED_COMMANDS = {
    "git_status": ["git", "status", "--short"],
    "git_branch": ["git", "branch", "--show-current"],
}


@mcp.tool()
def run_safe_command(name: str) -> str:
    if name not in ALLOWED_COMMANDS:
        raise ValueError("command is not allowed")

    return subprocess.check_output(
        ALLOWED_COMMANDS[name],
        cwd=PROJECT_ROOT,
        text=True,
        timeout=10,
    )

The key idea is: let the model choose a business action, not compose an arbitrary shell string.

Summary

The biggest risk in an MCP Server is often not code complexity. It is a tool boundary that is too wide. A practical safer version should include path limits, type allowlists, size limits, and audit logs.

This does not replace a full permission system, but it puts local tool calls under control first. When you later connect databases, internal APIs, or remote services, keep using the same least-privilege tool design.

References:

  • Model Context Protocol documentation: https://modelcontextprotocol.io/docs
  • MCP Python SDK: https://github.com/modelcontextprotocol/python-sdk

友情链接

其它