Security Model and Permissions
How MCP handles authentication, authorisation, and sandboxing tool execution safely.
Tool access is power, so MCP security has to be explicit by default. The clearest way to understand why is to look at a server that gets it wrong.
# VULNERABLE: trusts the model's "path" argument completely
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> CallToolResult:
if name == "read_file":
path = arguments["path"]
with open(f"/data/user_files/{path}") as f:
return CallToolResult(content=[TextContent(type="text", text=f.read())])# FIXED: resolve the path and verify it stays inside the allowed directory
import os
ALLOWED_ROOT = os.path.realpath("/data/user_files")
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> CallToolResult:
if name == "read_file":
requested = os.path.realpath(os.path.join(ALLOWED_ROOT, arguments["path"]))
if not requested.startswith(ALLOWED_ROOT + os.sep):
return CallToolResult(
content=[TextContent(type="text", text="Access denied: path escapes allowed directory")],
isError=True,
)
with open(requested) as f:
return CallToolResult(content=[TextContent(type="text", text=f.read())])The fix is not 'be more careful', it is a structural check: resolve the real path and verify it is still inside the boundary, every single call, with no exceptions. The same pattern applies everywhere: the `postgres-server` in the companion repo blocks `INSERT/UPDATE/DELETE/DROP` with a regex before a query ever reaches the database, for exactly this reason.
Treat server boundaries as trust boundaries. Never assume the model will always call tools safely, because the model's input can itself be steered by untrusted content it read. Defensive validation belongs in the server's code, not just in the system prompt.
Giving an agent full admin credentials is like handing every tool in a workshop to a new intern on day one. Start with basic access and expand only when necessary.