LLMs are non-deterministic adversarial inputs.
This repository demonstrates how to build MCP servers that fail closed by default.
The Model Context Protocol lets a language model invoke server-side tools. The inputs to those tools come from the model. The model is shaped by its prompt, its training, and whatever content it just read — a web page, an email, a PDF, the output of another tool. Any of that content can carry instructions the operator never authorized.
In practical terms: the caller of your tool is sometimes an attacker, and you can't tell which call is which.
The usual mitigations for "untrusted user input" are necessary but not
sufficient. Sanitizing strings doesn't help when the attack is "call the tool
that wasn't supposed to exist." Type checks don't help when True is int in
Python. Trusting the framework doesn't help when the framework was designed
for a less hostile setting.
This repository is the smallest artifact that demonstrates a posture appropriate to the threat model. It's not a tutorial. It's a contract you can copy.
Every tool in this lab passes through three gates, in this order. The order is not negotiable.
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 1. Allowlist │ → │ 2. Validation │ → │ 3. Execute │
│ (before dispatch)│ │ (type, size, │ │ (pure function │
│ │ │ content) │ │ of bounded args)│
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │
▼ ▼
reject + log reject + log
ToolExecutionError ToolExecutionError
If any gate refuses, the call fails — it does not degrade, retry, fall back,
or coerce. The rejection raises a typed ToolExecutionError and emits one
WARNING log line on stderr.
The pattern is enforced at three levels:
- Code: dispatch is a literal
if/elif. There is nogetattr,eval,globals()[op], oroperator.__dict__[op]anywhere underprojects/. - Tests: every gate has at least one rejection test. A gate without a rejection test is a gate that doesn't exist.
- Threat model: each lab ships with a
threat_model.mdmapping abuse → mitigation → log line, organized by family (prompt injection, overflow, DoS, unauthorized execution).
The reference server (projects/mcp-server-basic) exposes two tools that do
nothing interesting: a four-operation calculator and a string echo. They
exist so the gates have something concrete to refuse.
compute("pow", 2, 10)
# ToolExecutionError: operacao 'pow' nao permitida.
# Allowlist: ['add', 'div', 'mul', 'sub']
# stderr → [WARNING] mcp-server-basic :: compute rejeitado:
# operacao fora da allowlist ('pow')
compute("div", 1, 0)
# ToolExecutionError: divisao por zero
# stderr → [WARNING] mcp-server-basic :: compute rejeitado: divisao por zero
compute("add", 1e10, 1)
# ToolExecutionError: a excede limite (1000000000.0)
# stderr → [WARNING] mcp-server-basic :: compute rejeitado:
# a acima do limite (10000000000.0)
compute("add", float("nan"), 1)
# ToolExecutionError: a deve ser finito (sem NaN/Infinity)
# stderr → [WARNING] mcp-server-basic :: compute rejeitado:
# a nao finito (nan)
compute("add", True, 1) # True is int in Python — explicitly rejected
# ToolExecutionError: a deve ser numerico
# stderr → [WARNING] mcp-server-basic :: compute rejeitado:
# a nao numerico (<class 'bool'>)
echo("hi\x1b[31mred") # ANSI escape — terminal/log injection vector
# ToolExecutionError: text contem caractere de controle nao permitido
# stderr → [WARNING] mcp-server-basic :: echo rejeitado:
# caractere de controle '\x1b'Every line above corresponds to a row in
projects/mcp-server-basic/threat_model.md
and a passing test in
projects/mcp-server-basic/tests/test_server.py.
mcp-learning-lab/
├── README.md # this file
├── SECURITY.md # disclosure policy + explicit security model
├── CONTRIBUTING.md # bar for incoming PRs
├── LICENSE # MIT
├── pyproject.toml # deps + pytest config
├── .github/workflows/ci.yml # full test suite on every push
├── projects/
│ ├── mcp-server-basic/ # reference server (compute + echo)
│ │ ├── README.md
│ │ ├── mcp_server_basic.py # FastMCP wrapper + pure validated core
│ │ ├── threat_model.md # 4 categories × abuse × mitigation × log
│ │ └── tests/test_server.py
│ └── mcp-server-template/ # minimal skeleton — copy to start a new tool
│ ├── README.md
│ ├── mcp_server_template.py # allowlist + validation + fail-closed, no logic
│ ├── threat_model.md
│ └── tests/test_server.py
└── notes/
└── 01-mcp-fundamentals.md # MCP primer (concepts, transports)
projects/ holds runnable, tested servers. notes/ holds conceptual material.
mcp-server-template is the file you copy when you want to add a new tool —
it's deliberately empty of business logic.
- Python 3.10+ (the
mcpSDK requires it). - No system services, no network, no filesystem access from any tool in the lab.
git clone https://github.com/IanVDev/mcp-learning-lab.git
cd mcp-learning-lab
python3.13 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
# the full test suite — all gates, both servers
pytest
# run the reference server (stdio transport)
python projects/mcp-server-basic/mcp_server_basic.pyTo plug it into Claude Desktop, add to
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"basic": {
"command": "/absolute/path/to/.venv/bin/python",
"args": ["/absolute/path/to/projects/mcp-server-basic/mcp_server_basic.py"]
}
}
}Copy projects/mcp-server-template and edit. The template is the contract:
- Declare an
ALLOWED_*frozensetconstant. - Declare numeric bounds (
MAX_*) for every input that could grow. - Validate type, size, and content before any dispatch.
- Dispatch with
if/elif. Nevergetattr, nevereval. - Raise
ToolExecutionErroron every rejection. LogWARNINGwith the field name and a boundedrepr. - Write at least one rejection test per gate.
The full bar is in CONTRIBUTING.md. The template
already passes a rejection test out of the box — you should never see it red.
| Lab | Focus | Status |
|---|---|---|
mcp-server-basic |
Allowlist, validation, fail-closed, full threat model | shipped |
mcp-server-template |
Minimal skeleton for new tools — copy-and-edit base | shipped |
mcp-resources-lab |
Resources (read-only) with path-traversal guard | planned |
mcp-prompts-lab |
Versioned prompt templates | planned |
mcp-client-lab |
MCP client consuming a remote server | planned |
mcp-auth-lab |
OAuth 2.1 + per-tool scopes; rate-limit / log-flood mitigation | planned |
A new lab is mergeable when it ships with its own threat model and at least one test that exercises the security gate.
Disclosure policy and the full explicit security model are in
SECURITY.md. Do not open public issues for
vulnerabilities — use the GitHub Security Advisory channel.
The bar for new code is in CONTRIBUTING.md. Short
version: every new tool needs an allowlist or equivalent gate, explicit input
bounds, a WARNING log on rejection, and a test that proves the gate trips.
MIT.