Xiaobai
Developer · Builder
Building AI engineering systems, developer tools and long-term digital assets at XBSTACK.
About Xiaobai & XBSTACK →
MCP Resources vs Tools vs Prompts vs Roots: Secure File Access
MCP Resources vs Tools vs Prompts vs Roots: What is the difference between MCP Resources, Tools, Prompts, and Roots?
Direct answer: Resources are identifiable context supplied by the application; Tools are model-discoverable actions; Prompts are reusable task templates selected by users; and Roots are candidate filesystem boundaries supplied by the Client. Prefer Resources for stable reads, Tools for queries or side effects, and Prompts for reusable instructions. Roots are not a sandbox, so the Server must validate real paths, symlinks, Root membership, read/write policy, size limits, and audit fields on every access.
For -32700 Parse Error, Tool list failed, or stdout contamination, use the separate guide: How to Fix MCP -32700 Parse Error.
Specification status, August 5, 2026: MCP 2026-07-28 is now a stable specification. This page continues to explain Resources, Tools, Prompts, Roots, and file boundaries while distinguishing the stable stateless core,
server/discover, per-request metadata, and SDK/client adoption differences that still require version-specific verification. The 2025-11-25 session flow remains only as a migration comparison.
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:
user
↓
Client approved Roots
↓
rejected
↓
resolve / realpath..
↓
validate Root
↓
check, file,
↓
Resource / Tool permission
↓
permissionuser
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 = user
Server path validation = Execute
OS/container permissions = permission
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.
- Handle lifecycle by protocol era: MCP
2026-07-28no longer uses protocol-levelMcp-Session-Id; each request carries the protocol/client metadata required by the modern flow. Only legacy2025-11-25and earlier Streamable HTTP paths rely on protocol sessions. - Send
MCP-Protocol-Versionand the request metadata required by the target protocol through the current SDK/client. Do not reuse the legacy assumption that negotiation state is cached afterinitializeon the 2026-07-28 path.
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 | Identify the protocol era first: legacy 2025 checks initialize/tools capability; 2026-07-28 checks per-request metadata, discovery/method flow, and the tools/list response shape |
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 | Legacy Session | Troubleshoot a terminated/unknown session only for the 2025-era session flow; 2026-07-28 should not depend on a protocol-level session ID |
| 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 validates Origin and authentication, and handles state by protocol era: 2026-07-28 does not depend on protocol-level session IDs; legacy sessions remain compatibility-only.
- [ ] Use the current MCP Inspector and automated tests for the target protocol: legacy 2025 covers initialize/list/read/call; 2026-07-28 covers self-describing requests, discover when used, 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 was re-checked on 2026-08-15 against the current MCP 2026-07-28 specification, while keeping 2025-11-25 as legacy compatibility context:
- MCP 2026-07-28 Specification
- MCP 2026-07-28 Streamable HTTP
- MCP 2026-07-28 Release Notes
- MCP Specification 2025-11-25 (legacy compatibility)
- 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.
More to Explore
Topic hub →AI Engineering Weekly
Production changes, real failures, experiments and new XBSTACK assets.
DISCUSSION
Questions, verification and corrections
Sign in to comment. Every new comment is reviewed before publication; while pending, it is visible only to you and the administrator.