A local, self-contained MCP (Model Context Protocol) server implemented in Python using FastMCP. It exposes three callable tools over stdio transport that any MCP-compatible client (CLI or Python script) can invoke.
- Project Overview
- Architecture Overview
- Prerequisites
- Installation
- Configuration
- Running the MCP Server
- Exposed Tools
- Usage Examples
- Error Handling
- Development & Testing
- Limitations and Notes
python-fastmcp-server is a locally-executable MCP server that demonstrates stateful tool execution, read-only state queries, and sandboxed file I/O — all within a single Python process. It requires no external services, no database, and no network connectivity beyond the local stdio transport.
| Tool | Description |
|---|---|
run_simulation |
Advances an internal simulation by N steps, accumulating a running value and history. Supports optional reset. |
get_state |
Returns the current simulation state (step counter, current value, history). Read-only, safe to call repeatedly. |
read_write_file |
Reads or writes a UTF-8 text file within a configurable path allowlist. Includes path traversal protection and size limiting. |
The Model Context Protocol defines a standard interface for exposing callable tools to AI clients and CLI orchestrators. FastMCP wraps the MCP wire protocol behind a decorator-based Python API, handling tool registration, JSON Schema generation, and request dispatch automatically.
This server runs as a single-process, single-threaded Python application. Clients communicate with it over stdio transport — the client process spawns the server as a subprocess and exchanges JSON-RPC messages over stdin/stdout.
The server maintains one SimulationState instance as a module-level singleton for the lifetime of the process:
SimulationState
├── step: int # total steps executed since last reset (initial: 0)
├── value: float # running accumulated value (initial: 0.0)
└── history: list[float] # value snapshot after each step (initial: [])
All three tools share this single instance. State is in-memory only — it is not persisted to disk and resets when the server process exits.
Server starts → SimulationState initialised to defaults
↓
run_simulation(steps=N) → state advances by N steps
↓
get_state() → returns snapshot (no mutation)
↓
run_simulation(reset=True) → state reset to defaults, then advanced
↓
Server exits → all state lost
| Component | Version |
|---|---|
| Python | ≥ 3.11 |
| FastMCP | ≥ 2.0 |
| Pydantic | ≥ 2.0 |
- Python 3.11 or newer (
python --versionto verify) - pip (bundled with Python ≥ 3.4)
- No external services, databases, or network access required
cd python-fastmcp-serverbash / zsh:
python -m venv .venvPowerShell:
python -m venv .venvbash / zsh:
source .venv/bin/activatePowerShell:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
.venv\Scripts\Activate.ps1pip install -r requirements.txtThis installs fastmcp>=2.0, pydantic>=2.0, and pytest>=8.0.
Configuration is controlled via environment variables. All settings have safe defaults and are resolved once at server startup.
| Variable | Default | Description |
|---|---|---|
MCP_ALLOWED_ROOTS |
project data/ folder |
Separator-delimited list of absolute directory paths the read_write_file tool is permitted to access. Use ; on Windows and : on Linux/macOS. Defaults to <project_root>/data when not set. |
MCP_MAX_FILE_SIZE_BYTES |
1048576 (1 MiB) |
Maximum file size in bytes that read_write_file will read. Files larger than this limit are rejected with a SIZE_EXCEEDED error. |
Important: Always use absolute paths. On Windows use semicolons (
;) as the separator; on Linux/macOS use colons (:). Using a colon on Windows splits drive letters (e.g.C:→C+ path) and causesPATH_VIOLATIONerrors.
Linux / macOS (bash/zsh):
export MCP_ALLOWED_ROOTS="/home/user/project/data:/tmp/mcp-scratch"
fastmcp run mcp_server/server.pyWindows (PowerShell) — note the semicolon separator:
$env:MCP_ALLOWED_ROOTS = "C:\Workspace\project\data;C:\Temp\mcp-scratch"
fastmcp run mcp_server/server.pyexport MCP_MAX_FILE_SIZE_BYTES=10485760Note: If
MCP_ALLOWED_ROOTSis not set, the server defaults to<project_root>/data(resolved from the location offilesystem.py, not the process cwd). Create the directory if it does not exist:# Linux / macOS mkdir -p data# Windows PowerShell New-Item -ItemType Directory -Force -Path data
Two start commands are supported. Both start the server in stdio transport mode, ready to accept MCP requests.
fastmcp run mcp_server/server.pyFastMCP auto-selects the transport (stdio by default) and handles the server lifecycle.
python -m mcp_serverThe server starts silently and blocks, waiting for MCP JSON-RPC messages on stdin. There is no startup banner. To verify the server is running correctly, send a tools/list request from a client — the response should list all three tools:
{
"tools": [
{ "name": "run_simulation", ... },
{ "name": "get_state", ... },
{ "name": "read_write_file", ... }
]
}import asyncio
from fastmcp import Client
async def verify():
async with Client("mcp_server/server.py") as client:
tools = await client.list_tools()
for t in tools:
print(t.name)
asyncio.run(verify())Expected output:
run_simulation
get_state
read_write_file
Advances the internal simulation state by the specified number of steps. Each step computes new_value = current_value + input_value and appends the result to history. Optionally resets state to defaults before running.
| Parameter | Type | Required | Default | Constraints | Description |
|---|---|---|---|---|---|
steps |
integer | yes | — | 1 – 100 | Number of simulation steps to advance |
input_value |
number | no | 1.0 |
≥ 0.0 | Scalar added to the running value on each step |
reset |
boolean | no | false |
— | If true, resets state to initial values before running |
{
"ok": true,
"steps_executed": 3,
"state": {
"step": 3,
"value": 3.0,
"history": [1.0, 2.0, 3.0]
},
"metadata": {
"duration_ms": 0.042,
"reset_applied": false
}
}{
"ok": false,
"error": "steps must be between 1 and 100"
}Returns the current simulation state. Read-only — calling this tool never modifies state. Safe to call multiple times in succession.
None.
{
"ok": true,
"state": {
"step": 5,
"value": 5.0,
"history": [1.0, 2.0, 3.0, 4.0, 5.0]
}
}Reads or writes a UTF-8 text file. The target path must resolve within the configured MCP_ALLOWED_ROOTS. Path traversal attempts (including ../ and symlinks pointing outside allowed roots) are rejected before any I/O is performed.
| Parameter | Type | Required | Default | Constraints | Description |
|---|---|---|---|---|---|
path |
string | yes | — | Non-empty | Target file path (relative or absolute) |
operation |
string | yes | — | "read" or "write" |
Operation to perform |
content |
string | no | null |
Required when operation is "write" |
UTF-8 text to write |
encoding |
string | no | "utf-8" |
— | File encoding |
{
"ok": true,
"operation": "read",
"path": "/absolute/path/to/data/results.json",
"content": "{\"x\": 1}",
"size_bytes": 8
}{
"ok": true,
"operation": "write",
"path": "/absolute/path/to/data/output.txt",
"size_bytes": 5
}{
"ok": false,
"error": "path is outside allowed roots",
"error_code": "PATH_VIOLATION"
}| Code | Cause |
|---|---|
PATH_VIOLATION |
Resolved path is outside all allowed roots (includes ../ traversal and symlinks) |
FILE_NOT_FOUND |
Target file does not exist (read operations only) |
PERMISSION_DENIED |
OS-level permission denied |
SIZE_EXCEEDED |
File size exceeds MCP_MAX_FILE_SIZE_BYTES (read operations only) |
INVALID_INPUT |
Missing required field (e.g. content absent on write) or invalid path |
IO_ERROR |
Unexpected OS-level I/O error |
All examples use the fastmcp Python client. Run the server in one terminal and execute the client script in another — or use Client as a context manager which spawns the server automatically.
import asyncio
from fastmcp import Client
async def main():
async with Client("mcp_server/server.py") as client:
# Starting state: step=0, value=0.0, history=[]
result = await client.call_tool("run_simulation", {"steps": 3})
print(result)
asyncio.run(main())Expected response:
{
"ok": true,
"steps_executed": 3,
"state": {
"step": 3,
"value": 3.0,
"history": [1.0, 2.0, 3.0]
},
"metadata": {
"duration_ms": 0.051,
"reset_applied": false
}
}Each call to run_simulation accumulates on top of the current state. Calling it again with steps=2 would produce step=5, value=5.0, history=[1.0, 2.0, 3.0, 4.0, 5.0].
result = await client.call_tool("run_simulation", {
"steps": 2,
"input_value": 5.0
})Expected response (from a fresh server):
{
"ok": true,
"steps_executed": 2,
"state": {
"step": 2,
"value": 10.0,
"history": [5.0, 10.0]
},
"metadata": {
"duration_ms": 0.038,
"reset_applied": false
}
}result = await client.call_tool("run_simulation", {
"steps": 1,
"reset": True
})State is reset to step=0, value=0.0, history=[] first, then one step executes:
{
"ok": true,
"steps_executed": 1,
"state": {
"step": 1,
"value": 1.0,
"history": [1.0]
},
"metadata": {
"duration_ms": 0.029,
"reset_applied": true
}
}state = await client.call_tool("get_state", {})
print(state)Expected response (after the example in 8.1):
{
"ok": true,
"state": {
"step": 3,
"value": 3.0,
"history": [1.0, 2.0, 3.0]
}
}Calling get_state any number of times returns the same result without modifying anything.
# Write
write_result = await client.call_tool("read_write_file", {
"path": "data/notes.txt",
"operation": "write",
"content": "simulation complete"
})
print(write_result)
# Read back
read_result = await client.call_tool("read_write_file", {
"path": "data/notes.txt",
"operation": "read"
})
print(read_result)Write response:
{
"ok": true,
"operation": "write",
"path": "/absolute/path/to/data/notes.txt",
"size_bytes": 19
}Read response:
{
"ok": true,
"operation": "read",
"path": "/absolute/path/to/data/notes.txt",
"content": "simulation complete",
"size_bytes": 19
}The data/ directory is created automatically if it does not exist. Intermediate subdirectories in the path are also created as needed.
All recoverable errors return a consistent envelope. Raw Python tracebacks are never surfaced to the client.
{
"ok": false,
"error": "<human-readable description>",
"error_code": "<CODE>"
}error_code is present for filesystem errors. For validation errors (e.g. steps out of range), only ok and error are returned.
await client.call_tool("read_write_file", {
"path": "../../etc/passwd",
"operation": "read"
}){
"ok": false,
"error": "path is outside allowed roots",
"error_code": "PATH_VIOLATION"
}await client.call_tool("read_write_file", {
"path": "data/missing.txt",
"operation": "read"
}){
"ok": false,
"error": "file not found: data/missing.txt",
"error_code": "FILE_NOT_FOUND"
}await client.call_tool("run_simulation", {"steps": 0}){
"ok": false,
"error": "steps must be between 1 and 100"
}await client.call_tool("read_write_file", {
"path": "data/x.txt",
"operation": "write"
}){
"ok": false,
"error": "content is required for write operations",
"error_code": "INVALID_INPUT"
}result = await client.call_tool("run_simulation", {"steps": 0})
if not result.get("ok"):
error_code = result.get("error_code") # present for filesystem errors
message = result.get("error")
print(f"Tool failed [{error_code}]: {message}")The server process remains running after any recoverable error. You do not need to restart the server between failed calls.
pytest tests/ -vExpected output summary:
41 passed, 1 skipped
The skipped test (test_symlink_outside_root_rejected) requires OS-level symlink creation privileges, which are not available in all environments (e.g. Windows without elevated permissions).
| Module | What it covers |
|---|---|
tests/test_state.py |
SimulationState initial values, reset() method, singleton existence |
tests/test_simulation.py |
run_simulation normal advance, custom input_value, reset-before-run, invalid steps |
tests/test_state_query.py |
get_state initial response, post-simulation response, idempotency |
tests/test_filesystem.py |
Read, write with directory creation, path traversal rejection, symlink rejection, size limit, file-not-found, missing content |
You can invoke tools directly in Python without a running server, which is useful for debugging individual handlers:
from mcp_server.state import _state
from mcp_server.tools.simulation import run_simulation
from mcp_server.tools.state_query import get_state
_state.reset()
print(run_simulation(steps=3))
print(get_state())pytest tests/test_simulation.py -vpytest tests/ -k "reset" -vSimulationState is held in process memory. There is no persistence to disk, database, or any external store. Restarting the server returns all state to its initial values (step=0, value=0.0, history=[]). If you need durable state, save it via read_write_file before shutting down.
The read_write_file tool enforces a strict path allowlist (MCP_ALLOWED_ROOTS). By default only the ./data directory (relative to the server's working directory) is accessible. Attempts to access paths outside the allowlist — including ../ traversal sequences and symlinks that resolve outside the allowlist — are rejected with a PATH_VIOLATION error before any I/O is performed.
This server has no auth layer. It is designed for local development use only. Do not expose the stdio transport over a network socket or run it as a shared service without adding appropriate access controls.
The server runs in a single Python process. There is no multi-process state sharing. All tools share one SimulationState instance within that process.
Read operations are limited to MCP_MAX_FILE_SIZE_BYTES (default 1 MiB). Writes are not size-limited at the tool level but are subject to available disk space and OS permissions.