MCP File Server in Practice: Resources, Tools, Roots, and Secure Sandboxing
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?
| Capability | Primary Controller | Use Case | Typical Usage in a File Gateway |
|---|---|---|---|
| Resources | Application-driven | Provide identifiable, readable context | Project READMEs, configuration snapshots, log snippets, database schemas |
| Tools | Discoverable and callable by the model; applications should retain manual oversight | Actions for querying, computing, or producing side effects | Searching files, generating summaries, writing drafts, moving files |
| Prompts | User-initiated selection | Reusable task templates | ”Review current configuration”, “Summarize specified logs” |
| Roots | Provided by the Client to the Server | Declare boundaries for file system operations | Current 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 todrafts/, 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
- Accept only relative paths; explicitly reject absolute paths such as
/etc/passwdandC:\Users\.... - Resolve
..and existing symbolic links usingresolve/realpath. - Verify that the resolved path remains within the authorized root, rather than relying solely on string prefix matching.
- Default to read-only; register write, delete, and move operations as separate Tools.
- Restrict writes to specific subdirectories, such as
drafts/or temporary directories. - Limit file extensions, individual file sizes, character counts for reads, and the number of items in directory listings.
- Prohibit reading keys, credentials,
.env, private keys, and browser data directories. - Audit
tool_name, relative paths, operators, results, duration, and rejection reasons, but do not log sensitive content bodies. - Return stable error codes on failure; do not expose server absolute paths or full stack traces directly to the model.
- 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:
| Input | Result | Reason |
|---|---|---|
docs/readme.md | ALLOWED | The resolved real path remains within Root |
../.ssh/config | REJECTED | Normalization causes it to exceed Root |
/etc/passwd | REJECTED | Absolute path |
latest-log/config | REJECTED | Symbolic 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 beforerelative_to()to prevent../and symbolic link escapes.read_textaccepts only a text whitelist and limits file size and returned character count.write_draftcan only write Markdown files underdrafts/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:
| Transport | Suitable Scenarios | Key Risks |
|---|---|---|
| stdio | Local desktop clients, IDEs, single-user development environments | stdout pollution, PATH, working directory, subprocess permissions |
| Streamable HTTP | Cross-machine, team sharing, cloud services | Authentication, 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
Originto reject untrusted sources and prevent DNS Rebinding. - Bind local services only to
127.0.0.1; do not listen on0.0.0.0by 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-Versionafter 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
| Symptom | Layer | Priority Check |
|---|---|---|
-32700 Parse error | stdio / JSON-RPC | stdout contains plain text, message truncation, invalid JSON, encoding issues |
Tool list failed | Lifecycle / Capability Negotiation | Was initialize completed? Is the tools capability declared? Is the tools/list structure correct? |
spawn ENOENT | Local Process | Absolute path of command, virtual environment, PATH, working directory |
READ_REJECTED | Application Security | Absolute paths, directory traversal, sensitive directories, file types and sizes |
| HTTP 403 | Streamable HTTP | Origin, authentication, authorization scope |
| HTTP 404 with Session ID | Session | Has the session terminated? Should the client re-initialize? |
| Excessively large response content | Resource Governance | Pagination, 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:
- MCP Specification 2025-11-25
- MCP Resources
- MCP Tools
- MCP Prompts
- MCP Roots
- MCP Transports
- MCP Security Best Practices
Related articles on this site:
- MCP Server connecting to SQLite: Read-only queries and permission control
- MCP security best practices and boundary defense
- MCP JSON-RPC parse error troubleshooting
- MCP Streamable HTTP deployment
- MCP OAuth authentication
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 →MCP Server in Practice: 5 Steps and Pitfalls for Letting Claude Access Local SQLite
A step-by-step guide to building an MCP Server that connects to a local SQLite database, enabling true private financial ledger AI auditing and data sovereignty. Covers SQL allow/deny list filtering, secure pagination design, and strategies for summarizing large result sets.
How to Fix MCP -32700 Parse Errors: Troubleshooting stdout, stdio, and "Tool list failed"
Diagnose MCP -32700 Parse error, Unexpected non-JSON line, Tool list failed, and spawn ENOENT with a 30-second stdout/stderr check for macOS, Linux, and Windows PowerShell, then verify JSON-RPC lifecycle and protocol negotiation.
MCP OAuth Authentication in Practice: Why Remote MCP Servers Can't Go Unprotected
A practical guide to designing OAuth authentication and authorization for remote MCP servers, covering Protected Resource Metadata, Authorization Server Discovery, Bearer Tokens, Scopes, Resource Indicators, session isolation, and tool permission boundaries.
MCP Streamable HTTP in Practice: Deploying from Local stdio Server to Remote MCP Service
A practical guide on migrating MCP Servers from local stdio mode to remote deployment via Streamable HTTP, covering HTTP POST/GET, SSE streaming, session management, reverse proxy configuration, authentication boundaries, and production troubleshooting.

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.