Skip to main content

Building an MCP Server

The theory is clear. The protocol is understood. Now you build something real.

A platform engineering team had spent months watching their developers context-switch constantly: reading documentation in one window, checking configuration files in another, running scripts in a terminal, and asking Claude questions in a fourth window. The tools were all there - Claude was available, the docs were accessible, the configs were in the filesystem. But Claude had no way to read any of them. Every conversation required copy-pasting content from files into the chat manually. A developer who needed Claude's help understanding a complex configuration file had to paste it in by hand. A developer debugging a build issue needed to manually copy error logs. The AI assistant was intelligent but blind.

The platform team had heard about MCP. They looked at the specification, looked at the Python SDK examples, and decided to build a filesystem server - give Claude the ability to list and read files in specific directories. The implementation took an afternoon. The impact was immediate: developers could ask Claude to read the actual config file, Claude would read it directly, and conversations that previously required 10 minutes of manual copy-pasting now happened in seconds.

The team had assumed building an MCP server would be complicated. It was not. The SDK abstracts away the protocol entirely - you define what your tools do, the SDK handles every communication detail.

This lesson takes you through that exact process. You will build a real, working MCP filesystem server with four tools and resource access, test it with the MCP Inspector, connect it to Claude Desktop, and add the production patterns that separate a demo from a deployable system. Every line of code in this lesson runs.


Why Build Your Own MCP Server

The growing ecosystem of community MCP servers covers many common needs. But you will need to build your own when:

You have internal tools with no public API. Your internal deployment system, proprietary data warehouse, custom monitoring stack - no community server exists. Building one takes hours and pays dividends immediately.

Security constraints prevent using third-party servers. Community servers are designed for general use. If your data is sensitive, you need a server you control, deployed in your infrastructure, with your authentication.

You need custom logic and authorization. The community filesystem server gives broad access. A custom server enforces which directories are accessible, which file types are readable, and which write operations require approval.

You want a clean, auditable interface to your systems. A well-designed MCP server provides a single controlled access point. It enforces authorization, logs every operation, and makes AI tool use reviewable.


:::tip 🎮 Interactive Playground Visualize this concept: Try the MCP Architecture demo on the EngineersOfAI Playground - no code required. :::

Historical Context

The MCP Python SDK was released alongside the protocol specification in November 2024, maintained by Anthropic as the official reference implementation. It abstracts away JSON-RPC, transport management, and session lifecycle, letting you focus on what your tools actually do. The TypeScript SDK was released simultaneously; Go and Rust community implementations appeared within weeks. As of early 2025, the Python SDK is the most commonly used for server development, with the TypeScript SDK widely used for browser-based and Node.js deployments.


Setup

# Create a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate

# Install the MCP SDK
pip install mcp

# For async file I/O
pip install aiofiles

# Verify installation
python -c "import mcp; print('MCP SDK installed')"

The mcp package includes:

  • mcp.server - Server class and decorators
  • mcp.server.stdio - stdio transport (subprocess communication)
  • mcp.server.sse - HTTP+SSE transport (remote/shared)
  • mcp.types - All protocol type definitions
  • mcp.client - Client implementation (for testing)

The Server We Are Building

A filesystem MCP server that gives AI assistants controlled, safe access to the local file system.

Tools:

  1. list_files - List files and directories with metadata
  2. read_file - Read file content with optional line range
  3. write_file - Write content to a file (with audit logging)
  4. search_files - Search by name pattern or file content

Resources:

  • file:// resources for text files in allowed directories

Security model:

  • All operations scoped to configured allowed directories
  • Symlink traversal prevented via Path.resolve()
  • Write operations logged with path, size, timestamp

Step 1: Project Structure and Configuration

# filesystem_server.py
"""
Filesystem MCP Server
Gives AI assistants controlled read/write access to specified directories.

Install: pip install mcp aiofiles
Run: python filesystem_server.py
Test: npx @modelcontextprotocol/inspector python filesystem_server.py
"""

import asyncio
import fnmatch
import logging
import os
import json
import time
from pathlib import Path

import aiofiles
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import (
Tool,
Resource,
TextContent,
CallToolResult,
TextResourceContents,
ResourceContents,
)

# ─────────────────────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────────────────────

def _load_allowed_dirs() -> list[str]:
"""Load allowed directories from environment variable or fall back to defaults."""
env_val = os.environ.get("MCP_ALLOWED_DIRS", "")
if env_val:
return [d.strip() for d in env_val.split(":") if d.strip()]
return [
str(Path.home() / "documents"),
str(Path.home() / "workspace"),
"/tmp/ai-workspace",
]

ALLOWED_DIRECTORIES: list[str] = _load_allowed_dirs()
MAX_FILE_SIZE_BYTES: int = 1_000_000 # 1 MB
MAX_SEARCH_RESULTS: int = 50

# ─────────────────────────────────────────────────────────────
# Logging - NEVER to stdout (used by MCP protocol)
# ─────────────────────────────────────────────────────────────

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.FileHandler("/tmp/filesystem-mcp.log"),
logging.StreamHandler(__import__("sys").stderr),
]
)
logger = logging.getLogger("filesystem-server")

# ─────────────────────────────────────────────────────────────
# Server instance
# ─────────────────────────────────────────────────────────────

app = Server("filesystem-server")

:::danger Never Write to stdout in an MCP Server When using stdio transport, stdout is the communication channel for JSON-RPC messages. Any print() call or log handler writing to stdout will corrupt the protocol stream, causing the client to fail with JSON parse errors. All logging must go to stderr or a file. This is the most common cause of "connects but immediately fails" issues in new MCP servers. :::

Step 2: Security Helpers

The path validation function is the most critical piece of security in a filesystem server. It prevents directory traversal attacks:

# ─────────────────────────────────────────────────────────────
# Security
# ─────────────────────────────────────────────────────────────

def validate_path(path: str, must_exist: bool = True) -> Path:
"""
Validate that a path is within allowed directories.

Prevents attacks like:
../../etc/passwd (path traversal)
/etc/shadow (absolute path outside allowed dirs)
symlink -> /root/.ssh/id_rsa (symlink traversal)

Uses Path.resolve() which:
- Resolves all symlinks to their real targets
- Normalizes .. and . components
- Returns an absolute path

Returns the resolved Path if valid.
Raises PermissionError if outside allowed directories.
Raises FileNotFoundError if must_exist=True and path does not exist.
"""
resolved = Path(path).resolve()

for allowed in ALLOWED_DIRECTORIES:
allowed_resolved = Path(allowed).resolve()
try:
resolved.relative_to(allowed_resolved)
# Path is within this allowed directory - safe to proceed
if must_exist and not resolved.exists():
raise FileNotFoundError(f"Path does not exist: {path}")
return resolved
except ValueError:
continue # Not within this allowed directory - try next

raise PermissionError(
f"Path '{path}' is outside allowed directories. "
f"Allowed: {ALLOWED_DIRECTORIES}"
)


TEXT_EXTENSIONS = {
'.txt', '.md', '.mdx', '.py', '.js', '.ts', '.json', '.yaml', '.yml',
'.toml', '.cfg', '.ini', '.sh', '.bash', '.zsh', '.html', '.css',
'.xml', '.csv', '.sql', '.go', '.rs', '.java', '.c', '.cpp', '.h',
'.env', '.gitignore', '.dockerignore', 'Makefile', 'Dockerfile',
'.rb', '.php', '.swift', '.kt', '.r', '.scala', '.ex', '.exs',
}

def is_text_file(path: Path) -> bool:
"""Heuristic: is this file likely to be human-readable text?"""
return path.suffix.lower() in TEXT_EXTENSIONS or path.name in TEXT_EXTENSIONS

Step 3: Tool Definitions

# ─────────────────────────────────────────────────────────────
# Tools
# ─────────────────────────────────────────────────────────────

@app.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="list_files",
description=(
"List files and directories at the given path. Returns names, types "
"(file/directory), sizes in bytes, and last modification dates. "
"Use recursive=true to list all files in subdirectories. "
"Use pattern to filter (e.g., '*.py', '*.md'). "
f"Only works within: {', '.join(ALLOWED_DIRECTORIES)}"
),
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path to list."
},
"recursive": {
"type": "boolean",
"default": False,
"description": "List files in all subdirectories recursively."
},
"pattern": {
"type": "string",
"description": "Glob pattern to filter filenames (e.g., '*.py', 'config.*')"
}
},
"required": ["path"]
}
),
Tool(
name="read_file",
description=(
"Read the content of a text file. Returns the full file content. "
f"Files larger than {MAX_FILE_SIZE_BYTES // 1000}KB are truncated. "
"Optionally read a specific line range using start_line and end_line. "
"Use list_files to discover available files first."
),
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file to read."
},
"start_line": {
"type": "integer",
"description": "Start reading from this line number (1-indexed, optional)",
"minimum": 1
},
"end_line": {
"type": "integer",
"description": "Stop reading at this line number inclusive (optional)",
"minimum": 1
}
},
"required": ["path"]
}
),
Tool(
name="write_file",
description=(
"Write content to a file. Creates the file if it does not exist; "
"overwrites it if it does. "
"IMPORTANT: This permanently modifies the filesystem. "
"Confirm with the user before writing unless explicitly instructed to do so."
),
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to write. Must be within allowed directories."
},
"content": {
"type": "string",
"description": "Content to write to the file (full file content)."
},
"create_directories": {
"type": "boolean",
"default": False,
"description": "Create parent directories if they do not exist."
}
},
"required": ["path", "content"]
}
),
Tool(
name="search_files",
description=(
"Search for files by name pattern or by content. "
"Use name_pattern to find files matching a glob (e.g., '*.py', 'README.*'). "
"Use content_query to find files containing specific text (case-insensitive). "
"Both filters can be combined. Returns file paths and matching lines for content searches."
),
inputSchema={
"type": "object",
"properties": {
"base_path": {
"type": "string",
"description": "Directory to search within (searched recursively)."
},
"name_pattern": {
"type": "string",
"description": "Glob pattern for file names (e.g., '*.py')"
},
"content_query": {
"type": "string",
"description": "Text to search for within file contents"
},
"max_results": {
"type": "integer",
"default": 20,
"maximum": MAX_SEARCH_RESULTS
}
},
"required": ["base_path"]
}
)
]

Step 4: Tool Implementations

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> CallToolResult:
"""Route tool calls to the appropriate handler with unified error handling."""
logger.info(f"Tool called: {name}, args: {list(arguments.keys())}")

try:
if name == "list_files":
return await _list_files(**arguments)
elif name == "read_file":
return await _read_file(**arguments)
elif name == "write_file":
return await _write_file(**arguments)
elif name == "search_files":
return await _search_files(**arguments)
else:
return CallToolResult(
content=[TextContent(type="text", text=f"Unknown tool: '{name}'")],
isError=True
)
except PermissionError as e:
logger.warning(f"Permission denied in {name}: {e}")
return CallToolResult(
content=[TextContent(type="text", text=f"Permission denied: {e}")],
isError=True
)
except FileNotFoundError as e:
return CallToolResult(
content=[TextContent(type="text", text=f"Not found: {e}")],
isError=True
)
except Exception as e:
logger.error(f"Unexpected error in {name}: {e}", exc_info=True)
return CallToolResult(
content=[TextContent(type="text", text=f"Error: {e}")],
isError=True
)


async def _list_files(
path: str,
recursive: bool = False,
pattern: str | None = None
) -> CallToolResult:
dir_path = validate_path(path, must_exist=True)

if not dir_path.is_dir():
return CallToolResult(
content=[TextContent(
type="text",
text=f"'{path}' is a file. Use read_file to read it."
)],
isError=True
)

items = list(dir_path.rglob("*") if recursive else dir_path.iterdir())
items.sort(key=lambda x: (not x.is_dir(), x.name.lower()))

import datetime
entries = []
for item in items:
if pattern and not fnmatch.fnmatch(item.name, pattern):
continue
try:
stat = item.stat()
kind = "dir " if item.is_dir() else "file"
size = f"{stat.st_size:>10,} bytes" if item.is_file() else " -"
mtime = datetime.datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
rel = str(item.relative_to(dir_path))
entries.append(f"[{kind}] {size} {mtime} {rel}")
except OSError:
entries.append(f"[????] {item.name}")

if not entries:
msg = f"Directory is empty: {path}"
if pattern:
msg += f" (no matches for '{pattern}')"
else:
msg = f"Contents of {path}/ - {len(entries)} items\n\n" + "\n".join(entries)

return CallToolResult(
content=[TextContent(type="text", text=msg)],
isError=False
)


async def _read_file(
path: str,
start_line: int | None = None,
end_line: int | None = None
) -> CallToolResult:
file_path = validate_path(path, must_exist=True)

if file_path.is_dir():
return CallToolResult(
content=[TextContent(type="text", text=f"'{path}' is a directory. Use list_files.")],
isError=True
)

if not is_text_file(file_path):
return CallToolResult(
content=[TextContent(
type="text",
text=f"'{path}' appears to be a binary file and cannot be read as text."
)],
isError=True
)

file_size = file_path.stat().st_size
truncated = False

async with aiofiles.open(file_path, encoding="utf-8", errors="replace") as f:
if start_line or end_line:
all_lines = await f.readlines()
s = (start_line - 1) if start_line else 0
e = end_line if end_line else len(all_lines)
content = "".join(all_lines[s:e])
meta = f"Lines {s+1}{s+len(all_lines[s:e])} of {len(all_lines)}"
else:
if file_size > MAX_FILE_SIZE_BYTES:
content = await f.read(MAX_FILE_SIZE_BYTES)
truncated = True
meta = f"Size: {file_size:,} bytes - TRUNCATED to {MAX_FILE_SIZE_BYTES//1000}KB"
else:
content = await f.read()
meta = f"Size: {file_size:,} bytes"

header = f"=== {path} ===\n{meta}\n\n"
footer = "\n\n[TRUNCATED - use start_line/end_line for specific sections]" if truncated else ""
return CallToolResult(
content=[TextContent(type="text", text=header + content + footer)],
isError=False
)


async def _write_file(
path: str,
content: str,
create_directories: bool = False
) -> CallToolResult:
file_path = validate_path(path, must_exist=False)

if create_directories:
file_path.parent.mkdir(parents=True, exist_ok=True)
elif not file_path.parent.exists():
return CallToolResult(
content=[TextContent(
type="text",
text=f"Parent directory does not exist: {file_path.parent}. "
"Use create_directories=true to create it."
)],
isError=True
)

existed = file_path.exists()
async with aiofiles.open(file_path, mode="w", encoding="utf-8") as f:
await f.write(content)

action = "Updated" if existed else "Created"
# Audit log every write
logger.info(
f"WRITE | action={action} | path={path} | "
f"size={len(content)} | lines={content.count(chr(10))+1}"
)

return CallToolResult(
content=[TextContent(
type="text",
text=f"{action}: {path}\nSize: {len(content):,} bytes\nLines: {content.count(chr(10))+1}"
)],
isError=False
)


async def _search_files(
base_path: str,
name_pattern: str | None = None,
content_query: str | None = None,
max_results: int = 20
) -> CallToolResult:
if not name_pattern and not content_query:
return CallToolResult(
content=[TextContent(type="text", text="Provide name_pattern, content_query, or both.")],
isError=True
)

search_root = validate_path(base_path, must_exist=True)
max_results = min(max_results, MAX_SEARCH_RESULTS)
results = []

for file_path in search_root.rglob("*"):
if len(results) >= max_results:
break
if not file_path.is_file():
continue
if name_pattern and not fnmatch.fnmatch(file_path.name, name_pattern):
continue

if content_query:
if not is_text_file(file_path):
continue
if file_path.stat().st_size > MAX_FILE_SIZE_BYTES:
continue
try:
async with aiofiles.open(file_path, encoding="utf-8", errors="ignore") as f:
text = await f.read()
matches = []
for i, line in enumerate(text.splitlines(), 1):
if content_query.lower() in line.lower():
matches.append(f" L{i}: {line.strip()[:120]}")
if len(matches) >= 3:
break
if matches:
results.append(str(file_path) + "\n" + "\n".join(matches))
except OSError:
continue
else:
results.append(str(file_path))

if not results:
parts = []
if name_pattern:
parts.append(f"name matching '{name_pattern}'")
if content_query:
parts.append(f"content containing '{content_query}'")
return CallToolResult(
content=[TextContent(
type="text",
text=f"No files found in {base_path} with {' and '.join(parts)}"
)],
isError=False
)

header = f"Found {len(results)} results in {base_path}:\n\n"
return CallToolResult(
content=[TextContent(type="text", text=header + "\n\n".join(results))],
isError=False
)

Step 5: Resources

# ─────────────────────────────────────────────────────────────
# Resources
# ─────────────────────────────────────────────────────────────

@app.list_resources()
async def list_resources() -> list[Resource]:
"""Expose text files in allowed directories as readable resources."""
resources = []
for base in ALLOWED_DIRECTORIES:
base_path = Path(base)
if not base_path.exists():
continue
for fp in base_path.rglob("*"):
if not fp.is_file() or not is_text_file(fp):
continue
if fp.stat().st_size > MAX_FILE_SIZE_BYTES:
continue
ext = fp.suffix.lower()
mime = "text/markdown" if ext in (".md", ".mdx") else "text/plain"
resources.append(Resource(
uri=f"file://{fp}",
name=fp.name,
description=str(fp.relative_to(base_path.parent)),
mimeType=mime
))
return resources


@app.read_resource()
async def read_resource(uri: str) -> list[ResourceContents]:
"""Read a file by its file:// URI."""
if not uri.startswith("file://"):
raise ValueError(f"Unsupported URI scheme: {uri}")

path = uri[len("file://"):]
file_path = validate_path(path, must_exist=True)

async with aiofiles.open(file_path, encoding="utf-8", errors="replace") as f:
content = await f.read()

mime = "text/markdown" if file_path.suffix in (".md", ".mdx") else "text/plain"
return [TextResourceContents(uri=uri, mimeType=mime, text=content)]

Step 6: Entry Point

# ─────────────────────────────────────────────────────────────
# Entry point
# ─────────────────────────────────────────────────────────────

async def main():
logger.info(f"Filesystem MCP server starting")
logger.info(f"Allowed directories: {ALLOWED_DIRECTORIES}")

async with stdio_server() as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app.create_initialization_options()
)

if __name__ == "__main__":
asyncio.run(main())

Server Architecture


Testing with MCP Inspector

The MCP Inspector is the official tool for testing MCP servers interactively. It runs a browser-based UI to call tools manually and inspect responses.

# Install the MCP Inspector
npm install -g @modelcontextprotocol/inspector

# Run against your server
npx @modelcontextprotocol/inspector python filesystem_server.py

# With allowed dirs configured
MCP_ALLOWED_DIRS="/tmp/test-workspace" npx @modelcontextprotocol/inspector python filesystem_server.py

This opens http://localhost:5173. In the UI:

  • Tools tab: See all registered tools with their full schemas. Click any tool, fill in arguments, and call it.
  • Resources tab: List and read all available file resources.
  • History tab: See every JSON-RPC message exchanged - invaluable for debugging.

Systematic test checklist:

  • list_files on a valid directory - returns file listing
  • list_files with recursive=true - lists subdirectories
  • list_files with pattern="*.md" - filters correctly
  • read_file on a text file - returns content
  • read_file with start_line=1&end_line=10 - returns first 10 lines
  • read_file on /etc/passwd - returns permission error
  • write_file creates a new file
  • search_files finds files by name pattern
  • search_files finds files by content
  • Path traversal attempt ../../etc - rejected

Connecting to Claude Desktop

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json

Windows: %APPDATA%\Claude\claude_desktop_config.json

{
"mcpServers": {
"filesystem": {
"command": "/path/to/.venv/bin/python",
"args": ["/absolute/path/to/filesystem_server.py"],
"env": {
"MCP_ALLOWED_DIRS": "/Users/yourname/documents:/Users/yourname/projects"
}
}
}
}

Key details:

  • Use the Python executable from your virtual environment, not the system Python
  • All paths must be absolute
  • After editing the config, restart Claude Desktop - config is read at startup
  • Verify: ask Claude "What tools do you have available?" - it should list your filesystem tools

Automated Integration Tests

# test_filesystem_server.py
"""Run against a live server to validate all tools."""

import asyncio
import os
import tempfile
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def test_server():
with tempfile.TemporaryDirectory() as tmpdir:
# Seed test files
(Path(tmpdir) / "README.md").write_text("# Test Workspace\n\nTest content.")
(Path(tmpdir) / "config.yaml").write_text("key: value\ncount: 42")
(Path(tmpdir) / "subdir").mkdir()
(Path(tmpdir) / "subdir" / "nested.txt").write_text("Nested file content")

server_params = StdioServerParameters(
command="python",
args=["filesystem_server.py"],
env={"MCP_ALLOWED_DIRS": tmpdir}
)

async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
print("Connected to filesystem server")

# Test list_files
r = await session.call_tool("list_files", {"path": tmpdir})
assert not r.isError
assert "README.md" in r.content[0].text
print("PASS: list_files")

# Test read_file
r = await session.call_tool("read_file", {
"path": str(Path(tmpdir) / "README.md")
})
assert not r.isError
assert "Test Workspace" in r.content[0].text
print("PASS: read_file")

# Test write_file
new_path = str(Path(tmpdir) / "created.txt")
r = await session.call_tool("write_file", {
"path": new_path,
"content": "Written by MCP test"
})
assert not r.isError
assert os.path.exists(new_path)
print("PASS: write_file")

# Test search_files
r = await session.call_tool("search_files", {
"base_path": tmpdir,
"name_pattern": "*.md"
})
assert not r.isError
assert "README.md" in r.content[0].text
print("PASS: search_files - name pattern")

# Test content search
r = await session.call_tool("search_files", {
"base_path": tmpdir,
"content_query": "Nested"
})
assert not r.isError
assert "nested.txt" in r.content[0].text
print("PASS: search_files - content query")

# Test security: path traversal rejected
r = await session.call_tool("read_file", {"path": "/etc/passwd"})
assert r.isError
print("PASS: path traversal rejected")

print("\nAll integration tests passed!")

from pathlib import Path
asyncio.run(test_server())

Production Engineering Notes

Never Trust Input Paths

Every path from the model is a potential injection point. The validate_path() function using Path.resolve() and relative_to() is the canonical defense against directory traversal. Do not implement this with string comparisons - symlinks, .. components, and platform-specific path normalization make custom implementations unreliable. Always use Path.resolve().

Log Every Write

Read operations are safe to omit from audit logs. Write operations that modify the filesystem must be logged with: the path, size, timestamp, and session identifier. This audit trail is essential for diagnosing issues and for security review.

Handle Encoding Gracefully

Files in the real world contain encoding errors. Use errors='replace' when reading, not errors='strict'. A server that crashes on one bad character byte in one file is not production-ready.

File Size Caps

Without size caps, a request to read a large binary file will consume enormous context window space and may OOM the process. The 1 MB cap in this server is appropriate for most documentation and code review use cases.


:::warning Virtual Environment in Claude Desktop Config If your MCP server uses a virtual environment, use the Python executable from that venv in the Claude Desktop config - not the system Python. The system Python does not have your dependencies installed. Use the absolute venv path: /path/to/project/.venv/bin/python. :::


Interview Q&A

Q1: Walk through building a minimal working MCP server in Python.

Install with pip install mcp. Create a Server("my-server") instance. Register tool definitions using the @server.list_tools() decorator, returning a list of Tool objects with name, description, and inputSchema. Handle tool calls using @server.call_tool(), which receives the tool name and arguments dict, executes the operation, and returns a CallToolResult. Run using async with stdio_server() as (read, write): await app.run(read, write, ...). The entry point is straightforward - the SDK handles all JSON-RPC serialization, session management, and transport concerns. A working server with one tool takes about 50 lines.

Q2: What is the most critical security pattern for a filesystem MCP server?

Path validation to prevent directory traversal. The pattern: take the user-provided path string, call Path(path).resolve() to get the real absolute path with all symlinks resolved and all .. normalized, then call resolved.relative_to(allowed_directory) for each allowed directory. If relative_to() raises ValueError for all allowed directories, the path is outside the sandbox and must be rejected. Never use string comparison (path.startswith('/allowed/')) - it fails on symlinks and .. components. Always use Path.resolve() and relative_to(). This is the single most important line in any filesystem MCP server.

Q3: Why must you never write to stdout in an MCP server using stdio transport?

stdio transport uses the server's stdout as the communication channel for JSON-RPC protocol messages. The MCP client reads stdout and parses every byte as JSON-RPC. Any non-protocol output - a print statement, a Python warning, a log message - corrupts the protocol stream, causing the client to fail with a JSON parse error or a malformed response error. All logging must go to stderr (the MCP client ignores stderr), a log file, or a structured logging system. This is the single most common cause of "my server connects but immediately errors" in new MCP implementations.

Q4: How do you test an MCP server before integrating with Claude Desktop?

Two levels. First, the MCP Inspector: npx @modelcontextprotocol/inspector python server.py opens a browser UI where you can call every tool interactively, inspect schemas, read resources, and see every JSON-RPC message. This catches schema errors, permission issues, and response format problems without needing Claude. Second, automated integration tests using the mcp Python client library: connect to the server programmatically, call each tool with test inputs including adversarial ones (path traversal, missing required parameters, invalid types), and assert correct behavior. Run these in CI before every deployment. The Inspector is for exploratory testing and debugging; the integration tests are for regression prevention.

Q5: What is isError: true in a tool result and when do you use it vs. raising an exception?

isError: true is an application-level error: the tool executed successfully at the MCP protocol level, but the underlying operation failed. File not found, permission denied, rate limit exceeded - these return isError: true with a descriptive message. The MCP session stays healthy. The model reads the error message and can adjust its approach (try a different path, use a different tool, inform the user). Raising an exception in the tool handler is a server-level error: the SDK catches it and sends a JSON-RPC error response, which disrupts the session more severely. Use isError: true for expected failure cases. Use exceptions only for truly unexpected errors. The distinction matters because the model can recover gracefully from isError: true but cannot recover gracefully from a crashed session.

© 2026 EngineersOfAI. All rights reserved.