How to Make MCP Tool Guards Fail Closed in Python
Look at three MCP tool signatures:
call_api(url: str, method: str, body: str | None)
query_database(sql: str)
read_text_file(path: str)
Every one of those arguments arrives from a model. Not from your form validation, not from your typed client, not from a caller you reviewed. The transport hands you a JSON object and your function runs.
So the interesting question for each tool is not "does it work". It is "what happens when the argument is one I did not anticipate". And there are only two possible answers. Either the unanticipated case falls through to the effect, or it falls through to a refusal. That property has a name, fail closed, and it is a shape you can see in the code.
The shape: the last line is a refusal
Here is the version that fails open, which is the first thing I wrote:
def check_url(raw: str) -> str | None:
if raw.startswith("file://"):
return "blocked: file scheme"
if "169.254.169.254" in raw:
return "blocked: metadata endpoint"
if "localhost" in raw:
return "blocked: loopback"
return None # nothing matched, so we allow it
Every branch enumerates something bad. The final line is the allow. That means the set of things this function permits is "the entire internet, minus whatever I thought of while writing it", and that set includes http://127.0.0.1:6379, http://[::1]/, http://0.0.0.0/, http://2130706433/ (that is loopback as a decimal integer, and urlsplit will parse it happily), plus every internal hostname on your network you did not type into the function.
Flip the polarity and the last line becomes the refusal:
from urllib.parse import urlsplit
ALLOWED_HOSTS = {"api.example.com", "reports.internal.example.com"}
def check_url(raw: str) -> str | None:
parts = urlsplit(raw)
if parts.scheme not in ("http", "https"):
return "blocked: scheme not allowed"
host = (parts.hostname or "").lower()
if host in ALLOWED_HOSTS:
return None
return "blocked: host not in allowlist"
Same line count, opposite failure mode. Now the new-and-interesting URL a model composes is refused because it is new, and the only way to widen the tool is to edit ALLOWED_HOSTS, which is a diff a reviewer can read in one pass.
Note parts.hostname, not parts.netloc. hostname strips credentials and the port, so http://api.example.com@evil.test/ does not match, because its hostname is evil.test. Comparing against netloc is how you accidentally allow that one.
The same polarity, applied to SQL
A read-only database tool has the same decision to make, and the tempting version scans for DROP, DELETE, INSERT. That is a denylist again, with the extra problem that it is a text search pretending to be a parser.
Default-deny version: the statement must be a single statement, and it must be a SELECT.
import sqlite3
def check_sql(sql: str) -> str | None:
text = sql.strip().rstrip(";").strip()
if not text:
return "blocked: empty statement"
if ";" in text:
return "blocked: multiple statements"
if not sqlite3.complete_statement(text + ";"):
return "blocked: incomplete statement"
if text.split(None, 1)[0].upper() != "SELECT":
return "blocked: only SELECT is allowed"
return None
Three things worth saying out loud about that code.
The multi-statement check is why SELECT 1; DROP TABLE users cannot reach the driver. sqlite3.Cursor.execute only runs one statement, but executescript runs many, and a tool that grew a convenience path is exactly where a second statement gets through. Rejecting the semicolon at the text level means the refusal does not depend on which method the tool body happens to call.
complete_statement is stdlib and it is cheap, so use it. It catches the truncated or dangling input that would otherwise raise a driver error you then have to decide what to do with.
WITH is not in the allowlist, deliberately. SQLite lets a WITH clause sit in front of INSERT, UPDATE and DELETE, so "starts with WITH" is not a read-only guarantee. That costs you recursive CTEs in your read tool. I take that cost, because the alternative is a prefix check that quietly permits writes.
The semicolon rule costs you something too: SELECT * FROM t WHERE name = 'a;b' is refused even though it is a legitimate read. Default-deny guards refuse real work sometimes. That direction of error is survivable, the other direction is not.
And to file paths
from pathlib import Path
ROOT = Path("/srv/data").resolve()
def resolve_in_sandbox(raw: str) -> tuple[Path | None, str | None]:
candidate = (ROOT / raw).resolve()
if candidate.is_relative_to(ROOT):
return candidate, None
return None, "blocked: path outside the sandbox"
The ordering here is the whole mechanism, and it is easy to get backwards. ROOT / raw does not protect anything: if raw is absolute, Path("/srv/data") / "/etc/passwd" is Path("/etc/passwd"), because joining an absolute path discards the left side. The join is not the guard. resolve() then is_relative_to is the guard, and it has to run on the joined result, after normalization.
Doing it in that order gets you three cases for free: ../../etc/passwd collapses during resolve(), an absolute path collapses during the join, and a symlink inside the sandbox pointing outside it is followed by resolve() and then caught by the comparison. Checking the raw string for .. before joining catches the first case and misses the other two.
Wire the guard before the effect, and refuse with a string
@mcp.tool()
def read_text_file(path: str) -> str:
target, reason = resolve_in_sandbox(path)
if reason:
return reason
return target.read_text(encoding="utf-8", errors="replace")
Two habits worth keeping across all of your tools.
Validate every argument before the first side effect. A tool that opens the connection, then checks the SQL, has already opened the connection. A tool that starts streaming the request, then checks the host, has already made the request. The guard has to be the earliest thing in the body or it is not a guard, it is a log line.
Return the refusal as an ordinary string rather than raising. An uncaught exception crossing the MCP boundary tends to arrive in the transcript as a traceback, and a traceback from a path guard contains absolute paths from your filesystem. A short deterministic string ("blocked: path outside the sandbox") tells the model to stop trying without handing it a map of your disk.
What this does not solve
Be clear about the ceiling on all of this.
A host allowlist is a check on a name, not on a connection. It does not stop an allowed host from redirecting you somewhere else, so disable redirects or re-check after each hop. It does not stop DNS from resolving an allowed name to an internal address between your check and your connect. Closing that properly means resolving the name yourself, refusing private and link-local addresses, and connecting to the address you validated.
A text-level SQL guard is not a parser. It gives you single-statement, SELECT-only, and nothing more, and it says nothing about which tables or columns the model can read. You can add a second, independent layer by opening the database through a read-only URI, sqlite3.connect("file:app.db?mode=ro", uri=True), so that a defect in the text guard is not the only thing standing between a model and your writes. For column-level or row-level limits, put a view or a separate least-privilege database in front of the tool.
The path check is a check at a moment in time. If another process can rearrange that directory tree between your resolve() and your read_text_file, you have a race. For most local developer tooling that is acceptable. For a shared host with untrusted writers in the tree, it is not.
None of this is authorization, either. These guards limit what any caller can reach. They do not tell you which caller is asking. If different users need different scopes, that belongs in the transport and session layer, not in a per-tool allowlist.
And test the refusals, not the happy path. The allow case is the case you will exercise by hand on the first day. The deny cases are the ones that silently invert when someone refactors the guard into a helper and drops a return.
The Python MCP server I built, maintain and run with these guards in place, plus both transports and its tests, is packaged as the MCP Starter Kit: https://fulcrumenterprises.tech/go/mcp-starter-kit/?c=hashnode
