Skip to content

IanVDev/mcp-learning-lab

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mcp-learning-lab

LLMs are non-deterministic adversarial inputs.

This repository demonstrates how to build MCP servers that fail closed by default.

ci tests python license


The problem

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.


Fail-closed by default

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 no getattr, eval, globals()[op], or operator.__dict__[op] anywhere under projects/.
  • 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.md mapping abuse → mitigation → log line, organized by family (prompt injection, overflow, DoS, unauthorized execution).

What rejection looks like

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.


Repository layout

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.


Requirements

  • Python 3.10+ (the mcp SDK requires it).
  • No system services, no network, no filesystem access from any tool in the lab.

Quickstart

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.py

To 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"]
    }
  }
}

Adding a new tool

Copy projects/mcp-server-template and edit. The template is the contract:

  1. Declare an ALLOWED_* frozenset constant.
  2. Declare numeric bounds (MAX_*) for every input that could grow.
  3. Validate type, size, and content before any dispatch.
  4. Dispatch with if/elif. Never getattr, never eval.
  5. Raise ToolExecutionError on every rejection. Log WARNING with the field name and a bounded repr.
  6. 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.


Roadmap

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.


Security

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.

Contributing

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.

License

MIT.

About

Secure MCP learning lab (FastMCP reference implementation)

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages