MCP file server architecture: Resources, Tools, Roots, and secure sandboxing - XBSTACK

MCP File Server in Practice: Resources, Tools, Roots, and Secure Sandboxing

Release Date
2026-05-24
Reading Time
10分钟
Content Size
13,230 chars
MCP 协议
MCP Server
Resources
工具
Roots
Python
File Server
Security Sandbox
Laboratory Note

This article documents my real-world experiments in the lab. I believe that building your own digital assets with AI is the ultimate moat for developers.

Quick Answer

  • In the MCP 2025-11-25 specification, Resources provide application-driven context, Prompts are user-selected templates, and Tools are actions callable by the model; these three should not be conflated into a single interface simply because they are exposed by the Server.
  • Roots are merely candidate directory boundaries communicated by the client to the Server. Even if input does not contain '../', symbolic links can still redirect requests outside the workspace. The Server must validate the resolved real path again.
  • The current standard transports are stdio and Streamable HTTP. The legacy HTTP+SSE has been replaced by Streamable HTTP, where SSE is only an optional streaming response mechanism.
  • Production file gateways should default to read-only. Write operations should be registered separately with manual confirmation, while enforcing restrictions on file extensions, file size, return length, and audit fields.
  • In stdio mode, stdout can only carry valid MCP messages. Regular logs must be written to stderr or a separate log file.

Who Should Read This

  • Developers evaluating MCP / MCP Server / Resources / Tools for production use.
  • Indie builders who need a practical implementation path instead of another generic concept article.
  • Readers comparing architecture trade-offs, risks, tooling boundaries and next actions.

Problem Solved

  • What is the difference between MCP Resources, Tools, Prompts, and Roots?
  • How to implement a secure MCP file server?
  • Can MCP Roots prevent directory traversal?
  • How to choose between MCP stdio and Streamable HTTP?
  • Why does the MCP Server experience stdout pollution and Parse errors?

2026-07-15 Update Notes: This article has been refactored according to the MCP 2025-11-25 specification. The previous version incorrectly stated that remote transport was an “either/or choice between stdio and SSE”; this has now been corrected to stdio and Streamable HTTP. This update further includes a reproduction of symbolic link escape vulnerabilities, verification of attack paths after fixes, and the race condition risks that still need to be addressed in production environments.

When I re-examined this implementation, the first thing I encountered wasn’t ../

The client has already allowed the user to select a workspace, and the Server has received the Roots. On the surface, it appears the model can only operate within this directory, with boundaries seemingly clear enough.

However, when actually testing paths, problems immediately arose:

workspace/
├── docs/
├── drafts/
└── latest-log -> /Users/me/.ssh/

The only parameters requested for reading are:

latest-log/config

The input lacks ../ and is not an absolute path, but after resolving the symbolic link, the actual target lies outside the workspace. If the Server only checks the original string or merely verifies whether the client provided a Root, this read operation could still be permitted.

This is the most critical conclusion of this article: Roots are boundary declarations, not pre-configured security sandboxes.

The Key Point: Security Boundaries Must Be Enforced on Every File Operation

A production-ready MCP file gateway must ensure that every request passes through the following execution path in sequence:

用户选择可访问目录
        ↓
Client 通过 Roots 声明候选边界
        ↓
拒绝绝对路径
        ↓
resolve / realpath 解析 .. 与符号链接
        ↓
校验真实路径仍属于授权 Root
        ↓
检查敏感目录、文件类型、大小和读写策略
        ↓
Resource / Tool 权限控制
        ↓
低权限用户或容器形成最后物理边界

The most common mistake is treating the Roots token, the ALLOWED_ROOT environment variable, or the client workspace selection as the final execution boundary. What actually prevents directory traversal, absolute path access, symbolic link escapes, and unauthorized writes is the Server’s path resolution and access control executed during every operation.

MCP currently uses JSON-RPC 2.0. Protocol version and capability negotiation are completed when the connection is established. The Server can expose Resources, Prompts, and Tools; the Client can provide Roots, Sampling, and Elicitation capabilities. A file gateway should only implement the capabilities it truly needs rather than exposing all read/write permissions at once in the name of “feature completeness.”

What’s the Difference Between Resources, Tools, Prompts, and Roots?

CapabilityPrimary ControllerUse CaseTypical Usage in a File Gateway
ResourcesApplication-drivenProvide identifiable, readable contextProject READMEs, configuration snapshots, log snippets, database schemas
ToolsDiscoverable and callable by the model; applications should retain manual oversightActions for querying, computing, or producing side effectsSearching files, generating summaries, writing drafts, moving files
PromptsUser-initiated selectionReusable task templates”Review current configuration”, “Summarize specified logs”
RootsProvided by the Client to the ServerDeclare boundaries for file system operationsCurrent workspace, user-selected repository directories

Resources: How the Application Delivers Context to the Model

Resources are uniquely identified by URIs. Clients use resources/list to discover resources and resources/read to retrieve their content. They are suited for representing “an object that can be read,” not “an action to be executed.”

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "resources/read",
  "params": {
    "uri": "file:///workspace/README.md"
  }
}

Resources do not inherently equate to physical disk files. An file:// URI can represent a resource with filesystem semantics, while the Server may still read data internally from object storage, databases, or version control repositories. Regardless of the underlying source, you must validate the URI, check permissions, and limit the returned size.

Tools: Break actions into small pieces rather than registering an all-purpose shell

Tools expose their name, description, and input schema via tools/list, and are executed via tools/call. They can query databases, call APIs, compute results, write files, or trigger deployments; therefore, they carry significantly higher risk than read-only Resources.

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "write_draft",
    "arguments": {
      "relative_path": "drafts/plan.md",
      "content": "..."
    }
  }
}

Do not expose the following API:

run_command(command: string)
read_any_file(path: string)
write_any_file(path: string, content: string)

A safer design narrows capabilities into explicit actions:

  • search_markdown(query, limit): Search only Markdown files within allowed directories.
  • read_text(relative_path, max_chars): Read only whitelisted text formats and enforce length limits.
  • write_draft(relative_path, content): Write exclusively to drafts/, preventing overwrites of production content.
  • propose_patch(relative_path, patch): Return a diff for user confirmation before committing changes.

The MCP specification recommends that tool invocations always retain a human-in-the-loop rejection mechanism. For write, delete, outbound messaging, production deployment, and credential operations, this should not merely be a UI prompt but must also be enforced at the Server permission layer.

Prompts: Task entry points, not hidden system backdoors

Prompts are structured message templates exposed by the Server to the Client, typically selected by users via interface elements such as slash commands. They are well-suited for organizing “how to use Resources and Tools” into stable workflows. However, prompts cannot bypass Tool permissions and should never secretly inject high-risk operations invisible to the user.

Roots: Boundary declarations must coexist with Server-side validation

Clients supporting Roots can inform the Server of currently permitted directories via roots/list. The specification requires both the Client to validate Root URIs and implement access control, and the Server to respect Root boundaries and re-validate paths.

Therefore, the correct relationship is:

Roots = 用户和客户端认可的候选边界
Server path validation = 每次操作的强制执行边界
OS/container permissions = 最后的物理权限边界

Only when all three layers are in place can the risk of misreading a user’s home directory, .ssh, browser configurations, and production credentials be mitigated.

Security File Gateway 10 Item Checks

  1. Accept only relative paths; explicitly reject absolute paths such as /etc/passwd and C:\Users\....
  2. Resolve .. and existing symbolic links using resolve/realpath.
  3. Verify that the resolved path remains within the authorized root, rather than relying solely on string prefix matching.
  4. Default to read-only; register write, delete, and move operations as separate Tools.
  5. Restrict writes to specific subdirectories, such as drafts/ or temporary directories.
  6. Limit file extensions, individual file sizes, character counts for reads, and the number of items in directory listings.
  7. Prohibit reading keys, credentials, .env, private keys, and browser data directories.
  8. Audit tool_name, relative paths, operators, results, duration, and rejection reasons, but do not log sensitive content bodies.
  9. Return stable error codes on failure; do not expose server absolute paths or full stack traces directly to the model.
  10. Run the Server under a low-privilege system user or container, avoiding reliance on single-layer application code protection.

Python Implementation: Building a Reusable Path Sandbox

The following example demonstrates security boundaries only and does not assume the client supports any specific UI. It uses FastMCP to register two narrowed-down Tools: one for read-only text access and another for writing drafts.

from __future__ import annotations

import logging
import os
import sys
from pathlib import Path

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("SafeFileGateway")

logger = logging.getLogger("safe-file-gateway")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.handlers.clear()
logger.addHandler(handler)
logger.propagate = False

ROOT = Path(os.environ.get("MCP_ALLOWED_ROOT", "./sandbox")).expanduser().resolve()
DRAFT_ROOT = (ROOT / "drafts").resolve()
ROOT.mkdir(parents=True, exist_ok=True)
DRAFT_ROOT.mkdir(parents=True, exist_ok=True)

ALLOWED_READ_SUFFIXES = {".md", ".txt", ".json", ".yaml", ".yml", ".log"}
MAX_FILE_BYTES = 512 * 1024
MAX_RETURN_CHARS = 50_000


class PathRejected(ValueError):
    pass


def resolve_inside(root: Path, relative_path: str, *, must_exist: bool) -> Path:
    candidate = Path(relative_path)
    if candidate.is_absolute():
        raise PathRejected("absolute paths are not allowed")

    target = (root / candidate).resolve(strict=must_exist)
    try:
        target.relative_to(root)
    except ValueError as exc:
        raise PathRejected("path escapes the allowed root") from exc
    return target


def reject_sensitive_name(target: Path) -> None:
    lowered = {part.lower() for part in target.parts}
    blocked = {".ssh", ".gnupg", ".aws", ".env", "credentials", "secrets"}
    if lowered & blocked or target.name.lower().endswith((".pem", ".key", ".p12")):
        raise PathRejected("sensitive path is blocked")


@mcp.tool()
def read_text(relative_path: str, max_chars: int = 20_000) -> dict:
    """Read a bounded UTF-8 text file from the authorized root."""
    try:
        target = resolve_inside(ROOT, relative_path, must_exist=True)
        reject_sensitive_name(target)
        if not target.is_file():
            raise PathRejected("target is not a regular file")
        if target.suffix.lower() not in ALLOWED_READ_SUFFIXES:
            raise PathRejected("file type is not allowed")
        if target.stat().st_size > MAX_FILE_BYTES:
            raise PathRejected("file is larger than the configured limit")

        bounded = max(1, min(max_chars, MAX_RETURN_CHARS))
        text = target.read_text(encoding="utf-8")
        logger.info("read_text path=%s bytes=%s", relative_path, target.stat().st_size)
        return {
            "ok": True,
            "relative_path": relative_path,
            "text": text[:bounded],
            "truncated": len(text) > bounded,
        }
    except (OSError, UnicodeError, PathRejected) as exc:
        logger.warning("read_text rejected path=%s reason=%s", relative_path, exc)
        return {"ok": False, "error": "READ_REJECTED", "message": str(exc)}


@mcp.tool()
def write_draft(relative_path: str, content: str) -> dict:
    """Write a UTF-8 Markdown draft under drafts/. Never writes production files."""
    try:
        target = resolve_inside(DRAFT_ROOT, relative_path, must_exist=False)
        reject_sensitive_name(target)
        if target.suffix.lower() != ".md":
            raise PathRejected("drafts must use the .md suffix")
        encoded = content.encode("utf-8")
        if len(encoded) > MAX_FILE_BYTES:
            raise PathRejected("content is larger than the configured limit")

        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(encoded)
        logger.info("write_draft path=%s bytes=%s", relative_path, len(encoded))
        return {"ok": True, "relative_path": str(target.relative_to(DRAFT_ROOT))}
    except (OSError, UnicodeError, PathRejected) as exc:
        logger.warning("write_draft rejected path=%s reason=%s", relative_path, exc)
        return {"ok": False, "error": "WRITE_REJECTED", "message": str(exc)}


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

Verify the Fix with Four Paths

Don’t just assume the code “looks secure.” You must test all four paths: normal operation, directory traversal, absolute paths, and symbolic link escapes.

First, prepare a symbolic link in your test directory that points outside the root:

mkdir -p sandbox/docs
printf "ok" > sandbox/docs/readme.md
ln -s ~/.ssh sandbox/latest-log

Then reuse the above resolve_inside:

cases = [
    "docs/readme.md",
    "../.ssh/config",
    "/etc/passwd",
    "latest-log/config",
]

for relative_path in cases:
    try:
        target = resolve_inside(ROOT, relative_path, must_exist=False)
        print(f"ALLOWED  {relative_path} -> {target}")
    except PathRejected as exc:
        print(f"REJECTED {relative_path} -> {exc}")

The expected results should be:

InputResultReason
docs/readme.mdALLOWEDThe resolved real path remains within Root
../.ssh/configREJECTEDNormalization causes it to exceed Root
/etc/passwdREJECTEDAbsolute path
latest-log/configREJECTEDSymbolic link resolves to a location outside Root

If the fourth case is still allowed, it indicates that the implementation only checks strings rather than the actual file path.

Also note a more subtle issue: there is a time window between resolve() and the actual file open. In high-risk, multi-user, or environments with malicious local processes, simply performing “resolve first, then open” may still be vulnerable to TOCTOU race conditions. For production environments, further measures such as directory file descriptors, openat/dir_fd, O_NOFOLLOW, or container read-only mounts should be used to bind validation and opening within the same permission boundary.

This code has several deliberate constraints:

  • Absolute paths are not accepted.
  • Path.resolve() is executed before relative_to() to prevent ../ and symbolic link escapes.
  • read_text accepts only a text whitelist and limits file size and returned character count.
  • write_draft can only write Markdown files under drafts/ and cannot overwrite official content.
  • Logs are written to stderr, avoiding pollution of stdio protocol output.

Actual production deployments should also add user identity verification, tenant isolation, rate limiting, concurrency control, change approval workflows, and audit storage. File deletion and command execution should not be casually added to this basic example.

How to Choose Between stdio and Streamable HTTP

The standard transport defined by the MCP 2025-11-25 specification is:

TransportSuitable ScenariosKey Risks
stdioLocal desktop clients, IDEs, single-user development environmentsstdout pollution, PATH, working directory, subprocess permissions
Streamable HTTPCross-machine, team sharing, cloud servicesAuthentication, Origin validation, session hijacking, proxies, and timeouts

stdio: Local-first, but stdout must be absolutely clean

In stdio, the Client launches the Server as a subprocess, sending messages via stdin and receiving them via stdout. Each message is newline-delimited, and messages themselves cannot contain unencoded newlines. Standard logs can be written to stderr, but stdout must not contain any non-MCP messages.

Therefore, these coding practices could all trigger -32700 Parse error:

print("server started")
console.log("database connected")

Do not use a blunt fallback via sys.stdout = sys.stderr or by overriding process.stdout.write, as this may also break the SDK’s valid responses. The correct approach is to disable the dependent banner so that application logs explicitly use stderr; place uncontrollable dependencies in an isolated subprocess.

For a more complete troubleshooting flow, see: MCP -32700 Parse error troubleshooting.

Streamable HTTP: Not the legacy dual-endpoint SSE

Streamable HTTP replaces the HTTP+SSE Transport from the 2024-11-05 versions. The new implementation uses a single MCP endpoint to handle both POST and GET requests, for example:

https://example.com/mcp

The client sends a JSON-RPC message via POST. The server can return standard JSON or opt for streaming responses using text/event-stream. Remote deployments must at least implement the following:

  • Validate Origin to reject untrusted sources and prevent DNS Rebinding.
  • Bind local services only to 127.0.0.1; do not listen on 0.0.0.0 by default.
  • Enforce authentication and authorization for all remote connections.
  • Securely generate and handle MCP-Session-Id; never write session IDs to public logs.
  • Include the negotiated MCP-Protocol-Version after initialization.

For deployment details, see: MCP Streamable HTTP in Practice; for authorization boundaries, see: MCP OAuth Authentication in Practice.

Common Troubleshooting: Identify the Layer First

SymptomLayerPriority Check
-32700 Parse errorstdio / JSON-RPCstdout contains plain text, message truncation, invalid JSON, encoding issues
Tool list failedLifecycle / Capability NegotiationWas initialize completed? Is the tools capability declared? Is the tools/list structure correct?
spawn ENOENTLocal ProcessAbsolute path of command, virtual environment, PATH, working directory
READ_REJECTEDApplication SecurityAbsolute paths, directory traversal, sensitive directories, file types and sizes
HTTP 403Streamable HTTPOrigin, authentication, authorization scope
HTTP 404 with Session IDSessionHas the session terminated? Should the client re-initialize?
Excessively large response contentResource GovernancePagination, summaries, search limits, character caps, binary handling

When troubleshooting, start from the first anomaly. Do not focus solely on the last EPIPE or “connection closed” messages. These are usually cascading results of earlier parsing failures or process crashes.

Pre-launch Verification Checklist

  • [ ] The client exposes only Roots explicitly selected by the user.
  • [ ] The server re-validates Root boundaries for every file request.
  • [ ] Default capabilities are read-only; writes and deletions require separate authorization.
  • [ ] There are no universal shell, arbitrary path reading, or arbitrary URL requesting tools.
  • [ ] stdout contains only valid MCP messages; all logging goes to stderr or files.
  • [ ] File size, response length, concurrency, timeouts, and rate limits are capped.
  • [ ] Audit logs can answer “who did what, when, and on which relative path.”
  • [ ] The server runs under a low-privilege user or container, not as an administrator.
  • [ ] Streamable HTTP is configured with Origin validation, authentication, and secure session IDs.
  • [ ] Use MCP Inspector and automated tests to verify initialize, list, read/call, and error branches.

When Not to Use MCP

MCP standardizes capability discovery and context exchange between clients and servers; it does not automatically solve all permission, business logic, or deployment issues.

In the following scenarios, traditional APIs or single Function Calling are often simpler:

  • There is only a single fixed interface, and the caller is just one application.
  • The service already has a mature REST API, so multi-client auto-discovery is unnecessary.
  • Operations must go through complex transactions and strong-consistency approval workflows; existing business systems already handle these responsibilities.
  • The team lacks identity management, auditing, rate limiting, and key management, yet intends to expose the remote MCP to the public internet first.

For a more comprehensive selection comparison, see: MCP vs Function Calling.

Official Specification and Further Reading

This article cross-references the accessible MCP 2025-11-25 specification against the following 2026-07-15 standards:

Related articles on this site:

Topic path / MCP

Continue from protocol details to production MCP governance

The MCP hub connects protocol fundamentals, transports, authentication, security, JSON-RPC debugging and production deployment without splitting the search intent across isolated guides.

Next Reading

View Hub →
Xiaobai

Xiaobai

Full-Stack AI Engineer

Xiaobai, a full-stack AI engineer building production Agent systems, product tools and independent software assets.

About Xiaobai & XBSTACK →

Liked this article?
Join the newsletter

Every issue condenses production AI engineering changes, real failures, reproducible experiments, useful tools and new XBSTACK assets. No generic news digest and no filler.

Comments