Khác với 2 walkthrough trước (5 file phẳng), project roots/ có cấu trúc module:
- Đọc hiểu project architecture phức tạp hơn 2 walkthrough trước — có cấu trúc module, business logic tách riêng.
- Biết cách cung cấp roots qua CLI args khi khởi động client.
- Thấy được cách MCPClient wrapper expose roots tới server.
- Implement một tool thực tế (video_converter) có enforce roots đầy đủ.
- Extend project — thêm tool mới cần roots protection.
Project overview — Architecture phức tạp hơn
roots/
├── .env.example
├── .gitignore
├── main.py # Entry point — spawn client
├── mcp_client.py # MCPClient wrapper
├── mcp_server.py # MCP server
├── pyproject.toml
├── README.md
└── core/ # Business logic tách module
├── __init__.py
├── chat.py # Chat loop
├── claude.py # Anthropic SDK wrapper
├── cli_chat.py # CLI interface
├── cli.py # CLI args parser
├── tools.py # Tool orchestration
├── utils.py # Helper functions
└── video_converter.py # Business logic: convert video
Lý do architecture này: project này là full chat client với Claude, không chỉ demo MCP. main.py spawn client → client connect tới MCP server → tool convert_video có logic thật trong video_converter.py.
Setup:
cd ~/mcp-advanced
unzip roots.zip
cd roots/
cp .env.example .env
uv sync
brew install ffmpeg # macOS
main.py — Entry point & CLI args pattern
Đoạn quan trọng nhất:
import sys
import asyncio
from contextlib import AsyncExitStack
from core.chat import Chat
from core.claude import Claude
from mcp_client import MCPClient
async def main():
claude_model = "claude-sonnet-4-6"
claude = Claude(model=claude_model)
# Lấy root paths từ CLI arguments
root_paths = sys.argv[1:]
if not root_paths:
print("Usage: uv run main.py <root1> [root2] ...")
print("Example: uv run main.py /path/to/videos /another/path")
sys.exit(1)
clients = {}
async with AsyncExitStack() as stack:
# Create the MCP client với root directories
doc_client = await stack.enter_async_context(
MCPClient(
command="uv",
args=["run", "mcp_server.py"],
roots=root_paths, # ← inject roots vào client
)
)
clients["doc_client"] = doc_client
# Start chat loop
chat = Chat(clients=clients, claude_service=claude)
await chat.start()
if __name__ == "__main__":
asyncio.run(main())
Key observations
1. CLI args = root paths
uv run main.py /Users/jimmy/Movies /Users/jimmy/Desktop
Mọi argument sau main.py trở thành path. Đơn giản, explicit, dễ audit.
2. Defensive check
if not root_paths:
print("Usage: ...")
sys.exit(1)
Server cần ít nhất 1 root để hoạt động. Không có root = không có gì để access.
3. AsyncExitStack cho cleanup
async with AsyncExitStack() as stack:
doc_client = await stack.enter_async_context(MCPClient(...))
Pattern này đảm bảo mọi context manager cleanup khi stack exit. Production-grade.
mcp_client.py — MCPClient wrapper
Đây là lớp wrapper quan trọng — nó handle list_roots_callback transparently cho developer.
from pathlib import Path
from contextlib import asynccontextmanager
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp import ClientSession
from mcp.types import Root, ListRootsResult
class MCPClient:
def __init__(self, command: str, args: list[str], roots: list[str]):
self.command = command
self.args = args
self.roots = roots # ← store roots
self.session: ClientSession | None = None
async def list_roots_callback(self) -> ListRootsResult:
"""SDK gọi callback này khi server request roots."""
root_objects = [
Root(
uri=f"file://{Path(p).resolve()}",
name=Path(p).name or p,
)
for p in self.roots
]
return ListRootsResult(roots=root_objects)
async def __aenter__(self):
server_params = StdioServerParameters(
command=self.command,
args=self.args,
)
self._stdio_ctx = stdio_client(server_params)
read, write = await self._stdio_ctx.__aenter__()
self._session_ctx = ClientSession(
read, write,
list_roots_callback=self.list_roots_callback, # ← register
)
self.session = await self._session_ctx.__aenter__()
await self.session.initialize()
return self
async def __aexit__(self, *args):
await self._session_ctx.__aexit__(*args)
await self._stdio_ctx.__aexit__(*args)
async def call_tool(self, name: str, arguments: dict):
return await self.session.call_tool(name=name, arguments=arguments)
Dissect
1. list_roots_callback là async method của class
Mỗi khi server emit roots/list request, SDK gọi callback này. Callback return ListRootsResult.
2. file:// URI scheme
Root(uri=f"file://{Path(p).resolve()}", name=...)
resolve() đưa về absolute path. URI chuẩn format file:///absolute/path.
3. Re-use pattern
MCPClient là abstraction bạn có thể sao chép cho project khác. Thay list_roots_callback bằng logic của bạn (đọc config file, query DB, ...).
mcp_server.py — Server side với enforcement
from mcp.server.fastmcp import FastMCP, Context
from pathlib import Path
from pydantic import Field
from core.video_converter import convert_video as do_convert_video
mcp = FastMCP(name="video-tools")
import sys
from urllib.parse import urlparse, unquote
def _uri_to_path(uri: str) -> Path:
"""file:// URI → Path, handling Windows drive letters."""
parsed = urlparse(uri)
path = unquote(parsed.path)
if sys.platform == "win32" and path.startswith("/") and len(path) > 2 and path[2] == ":":
path = path[1:] # '/C:/foo' → 'C:/foo'
return Path(path).resolve()
async def is_path_allowed(ctx: Context, requested_path: str) -> bool:
"""Check if path is inside any approved root."""
roots_result = await ctx.session.list_roots()
requested = Path(requested_path).resolve()
for root in roots_result.roots:
root_path = _uri_to_path(root.uri)
try:
requested.relative_to(root_path)
return True
except ValueError:
continue
return False
@mcp.tool(
name="list_files",
description="List files in an accessible directory"
)
async def list_files(
directory: str = Field(description="Directory path to list"),
*,
ctx: Context,
) -> str:
if not await is_path_allowed(ctx, directory):
return f"[ERROR] {directory} is outside approved roots."
path = Path(directory)
if not path.is_dir():
return f"[ERROR] {directory} is not a directory."
files = [f.name for f in path.iterdir()]
return f"Files in {directory}:\n" + "\n".join(files)
@mcp.tool(
name="convert_video",
description="Convert video between formats (MP4, MOV, AVI, etc.)"
)
async def convert_video(
input_path: str = Field(description="Path to input video"),
output_format: str = Field(description="Output format: mp4, mov, avi, mkv"),
*,
ctx: Context,
) -> str:
# 1. Security check input
if not await is_path_allowed(ctx, input_path):
return f"[ERROR] Input {input_path} is outside approved roots."
# 2. Build output path
in_path = Path(input_path).resolve()
out_path = in_path.with_suffix(f".{output_format}")
# 3. Security check output (should also be in roots)
if not await is_path_allowed(ctx, str(out_path)):
return f"[ERROR] Output {out_path} would be outside approved roots."
# 4. Notify user
await ctx.info(f"Converting {input_path} → {out_path}")
await ctx.report_progress(0, 100, "Starting conversion...")
try:
# 5. Do actual conversion (notifications inside)
await do_convert_video(str(in_path), str(out_path), ctx)
except Exception as e:
return f"[ERROR] Conversion failed: {e}"
await ctx.report_progress(100, 100, "Done")
return f"Converted: {out_path}"
if __name__ == "__main__":
mcp.run()
Dissect — Best practices shown
1. Check cả input và output path
if not await is_path_allowed(ctx, input_path): ...
if not await is_path_allowed(ctx, str(out_path)): ...
User có thể nói "convert biking.mp4 to /etc/malicious.mov" → output ngoài roots → reject.
2. Graceful error message
Thay vì raise exception, return error message rõ. Claude có thể relay cho user hiểu rõ vì sao fail.
3. Notifications inline
await ctx.info(f"Converting {input_path} → {out_path}")
await ctx.report_progress(0, 100, "Starting conversion...")
Long-running tool → user see progress. Kết hợp bài 10.6.
4. Business logic tách module
from core.video_converter import convert_video as do_convert_video
Tool function mỏng — security + orchestration. Heavy work ở video_converter.py. Dễ test, dễ reuse.
core/video_converter.py — Business logic
import asyncio
import subprocess
from pathlib import Path
from mcp.server.fastmcp import Context
async def convert_video(input_path: str, output_path: str, ctx: Context):
"""Invoke ffmpeg to convert. Emit progress via ctx."""
cmd = [
"ffmpeg",
"-i", input_path,
"-y", # overwrite output
output_path,
]
await ctx.info(f"Running: {' '.join(cmd)}")
# Spawn ffmpeg, stream stderr (ffmpeg prints progress ra stderr)
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Monitor progress (parse ffmpeg output)
progress = 10
while True:
line = await process.stderr.readline()
if not line:
break
line_str = line.decode(errors="replace").strip()
# Progress simulation
if "frame=" in line_str:
progress = min(progress + 5, 90)
await ctx.report_progress(progress, 100, line_str[:60])
await process.wait()
if process.returncode != 0:
stderr_full = await process.stderr.read()
raise RuntimeError(f"ffmpeg failed: {stderr_full.decode()}")
Dissect
1. Run ffmpeg as subprocess
asyncio.create_subprocess_exec(*cmd, stdout=..., stderr=...)
Async so main tool function không block.
2. Parse stderr cho progress
FFmpeg log progress ra stderr. Parse line để emit MCP progress notification.
3. Error handle return code ≠ 0
Raise RuntimeError → tool catch → report error message.
Chạy project
cd roots/
uv run main.py ~/Movies ~/Desktop
Chat interface xuất hiện. Thử:
You: convert biking.mp4 to mov
Claude: Let me convert that for you...
[Tool call: convert_video(input_path="biking.mp4", output_format="mov")]
[ERROR] biking.mp4 is outside approved roots.
Wait — bị reject. Vì Claude gửi path relative, không match root. Fix bằng cách:
Solution A: Prompt engineer để Claude dùng full path:
"When users reference files, use full absolute paths based on available roots."
Solution B: Tool auto-search trong roots:
@mcp.tool()
async def convert_video(input_path, output_format, *, ctx):
# If not absolute, search in roots
if not Path(input_path).is_absolute():
roots = await ctx.session.list_roots()
for root in roots.roots:
candidate = Path(root.uri.replace("file://", "")) / input_path
if candidate.exists():
input_path = str(candidate)
break
else:
return f"[ERROR] File {input_path} not found in approved roots."
# ... rest
Thử lại:
You: convert biking.mp4 to mov
Claude: [Tool call: convert_video(...)]
Converting /Users/jimmy/Movies/biking.mp4 → /Users/jimmy/Movies/biking.mov
[Progress 25% ...]
[Progress 50% ...]
[Progress 90% ...]
Done! File created at /Users/jimmy/Movies/biking.mov
End-to-end thành công: Claude tự tìm file trong roots → convert → output cũng trong roots. Secure và ergonomic.
Mở rộng project — Bài tập modify
Exercise 1: Add read_file tool
@mcp.tool()
async def read_file(
file_path: str,
*,
ctx: Context,
) -> str:
if not await is_path_allowed(ctx, file_path):
return f"[ERROR] {file_path} is outside approved roots."
path = Path(file_path)
if not path.is_file():
return f"[ERROR] {file_path} is not a file."
# Size limit
if path.stat().st_size > 1_000_000:
return f"[ERROR] File too large (>{1_000_000} bytes)"
content = path.read_text(errors="replace")
return content
Note: size limit quan trọng — prevent reading /dev/zero hoặc massive log file.
Exercise 2: Add write_file tool với confirmation
@mcp.tool()
async def write_file(
file_path: str,
content: str,
*,
ctx: Context,
) -> str:
if not await is_path_allowed(ctx, file_path):
return f"[ERROR] {file_path} is outside approved roots."
await ctx.warning(f"About to write {len(content)} bytes to {file_path}")
path = Path(file_path)
path.write_text(content)
return f"Wrote {len(content)} bytes to {file_path}"
Prod version nên có confirmation prompt qua sampling:
confirm = await ctx.session.create_message(
messages=[SamplingMessage(role="user", content=TextContent(
type="text",
text=f"User wants to write to {file_path}. Do you approve? (yes/no)"
))],
max_tokens=10,
)
if "yes" not in confirm.content.text.lower():
return "[CANCELLED] User did not approve."
Exercise 3: Test path traversal attacks
Create file roots/attack-test.sh:
#!/bin/bash
uv run main.py /tmp/sandbox <<EOF
read_file /etc/passwd
read_file ../../../etc/passwd
read_file /tmp/sandbox/../../../etc/passwd
read_file /tmp/sandbox/symlink-to-etc-passwd
EOF
Create symlink: ln -s /etc/passwd /tmp/sandbox/symlink-to-etc-passwd.
Run. Expect: all reject (denied). Nếu có pass → bug trong is_path_allowed.
Tích hợp với Claude Desktop
Gắn server này vào Claude Desktop:
{
"mcpServers": {
"video-tools": {
"command": "uv",
"args": ["run", "/path/to/roots/main.py", "/Users/jimmy/Movies"]
}
}
}
Issue: args ở đây đi vào main.py, nhưng main.py lại spawn mcp_server.py — roots không được forward đúng chỗ. Cần refactor:
- Tách
mcp_server.pyđứng riêng (không cầnmain.pywrapper). - Đọc roots từ env var thay vì CLI args.
import os
ROOTS = os.environ.get("MCP_ROOTS", "").split(":")
Pattern này decouple client từ server invocation — cleaner cho real-world.
Debug tips
Tip 1: Log roots khi initialize
await ctx.info(f"Roots: {[r.name for r in (await ctx.session.list_roots()).roots]}")
Tip 2: Inspector shows roots dialog
MCP Inspector có panel "Roots" — dev config roots trực tiếp từ UI, không cần CLI args. Hữu ích cho quick test.
Tip 3: Test với empty roots
uv run main.py
Expected: error "Usage: ..." + exit. Handle gracefully trong production.
Tip 4: Permission denied từ OS
Dù bạn grant root, OS vẫn cần permission. File chmod 600 bởi user khác → server không read được dù trong root.
Anti-patterns từ walkthrough
Root path hardcode trong mcp_server.py
- Hiện tượng
ROOTS = ["/Users/jimmy/Movies"]trong code.- Cách đúng
- Nhận từ client dynamic qua
list_roots(). Code reusable.
Skip Path.resolve()
- Hiện tượng
- Compare raw path → vulnerable.
- Cách đúng
- Luôn
.resolve()trước khi compare.
Không sanitize output format
Hiện tượng: output_format từ user → thành extension file. User inject ../../evil → bypass.
Cách đúng:
if not re.match(r'^[a-z0-9]+$', output_format):
return "[ERROR] Invalid format"
Unicode normalization missing
- Hiện tượng
- Path có ký tự Unicode → NFC/NFD form khác nhau → compare fail.
- Cách đúng
unicodedata.normalize('NFC', path)trước compare trên macOS (macOS dùng NFD tiềm ẩn).
Áp dụng ngay
Bài tập 1: Full security audit (30 phút)
- Bước 1
- Clone
roots/project. - Bước 2
- Thử 10 attack path:
../../../etc/passwd- Symlink to outside
- Unicode variants
- Case variations
- Trailing slashes
- Double slashes
// - Whitespace in path
- NULL byte
\x00 - Very long path (> 4096 chars)
- Empty string
- Bước 3
- Tool nên reject ALL. Ghi ra case nào pass (= bug).
- Bước 4
- Fix bugs, test lại.
Bài tập 2 (optional): Build roots-aware tool của bạn
Build tool cho use case của bạn (PDF reader, git log viewer, ...) với:
- Roots enforcement.
- Notifications.
- Size limits.
- Audit log.
Deploy vào Claude Desktop, dùng 1 tuần, note những pattern tốt/tệ.
Tóm tắt bài học
- Project
roots/phức tạp hơn — có modulecore/, CLI args, wrapperMCPClient. - Roots cung cấp qua CLI args — explicit và dễ audit.
MCPClient.list_roots_callbacklà entry point — SDK gọi khi server request.is_path_allowedphải dùngPath.resolve().relative_to()— string match là bug.- Check cả input và output path — attacker có thể trick qua output.
- Tool mỏng, business logic ở module riêng — ergonomic và testable.
- OS permission vẫn là layer cuối — roots không bypass filesystem permission.
Tài liệu tham khảo
roots.zip— project chính thức (lấy link từ course gốc Anthropic Academy)- MCP Roots Spec
- FFmpeg docs — cho business logic
- OWASP Path Traversal — security reference