XBSTACK XBSTACK
Xiaobai

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 comparison and MODEL CONTEXT PROTOCOL article cover

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?

Published · 2026-05-2410 min readXBSTACK
#MCP#MCP Server#Resources#Tools#Roots#Python#File Server#Security Sandbox

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?

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 = 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

  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.
  • Handle lifecycle by protocol era: MCP 2026-07-28 no longer uses protocol-level Mcp-Session-Id; each request carries the protocol/client metadata required by the modern flow. Only legacy 2025-11-25 and earlier Streamable HTTP paths rely on protocol sessions.
  • Send MCP-Protocol-Version and the request metadata required by the target protocol through the current SDK/client. Do not reuse the legacy assumption that negotiation state is cached after initialize on 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

SymptomLayerPriority Check
-32700 Parse errorstdio / JSON-RPCstdout contains plain text, message truncation, invalid JSON, encoding issues
Tool list failedLifecycle / Capability NegotiationIdentify 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 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 IDLegacy SessionTroubleshoot 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 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 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:

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.

More to Explore

Topic hub →
MCP Server in Practice: 5 Steps and Pitfalls for Letting Claude Access Local SQLiteBuild an MCP Server for a local SQLite database so Claude or Cursor can query private financial data with schema validation, safe SQL, access controls, and audit logs.MCP OAuth Authentication in Practice: Why Remote MCP Servers Can't Go UnprotectedMCP OAuth Authentication in Practice: A practical guide to designing OAuth authentication and authorization for remote MCP servers, covering Protected Resource Metadata.MCP Streamable HTTP in Practice: From Local stdio to a Remote MCP ServerDeploy MCP Streamable HTTP with the 2026-07-28 protocol and Python SDK, covering stateless requests, proxies, auth, Origin checks, timeouts, and legacy compatibility.MCP Filesystem Server in Practice: Enabling Claude / Cursor to Securely Read Local FilesBuild a secure MCP Filesystem Server for Claude or Cursor with Roots, path allowlists, read-only tool scope, symlink/path checks, Prompt Injection defenses, and audit boundaries.

AI Engineering Weekly

Production changes, real failures, experiments and new XBSTACK assets.

Comments & evidence

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.

Sign-in required Reviewed before public
Loading the discussion…