Skip to content

jaclisan/python-fastmcp-server

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

python-fastmcp-server

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.


Table of Contents

  1. Project Overview
  2. Architecture Overview
  3. Prerequisites
  4. Installation
  5. Configuration
  6. Running the MCP Server
  7. Exposed Tools
  8. Usage Examples
  9. Error Handling
  10. Development & Testing
  11. Limitations and Notes

1. Project Overview

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.

Exposed Tools

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.

2. Architecture Overview

MCP and FastMCP

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.

Internal State Model

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.

State Lifecycle

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

Technologies

Component Version
Python ≥ 3.11
FastMCP ≥ 2.0
Pydantic ≥ 2.0

3. Prerequisites

  • Python 3.11 or newer (python --version to verify)
  • pip (bundled with Python ≥ 3.4)
  • No external services, databases, or network access required

4. Installation

1. Enter the project directory

cd python-fastmcp-server

2. Create a virtual environment

bash / zsh:

python -m venv .venv

PowerShell:

python -m venv .venv

3. Activate the virtual environment

bash / zsh:

source .venv/bin/activate

PowerShell:

Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
.venv\Scripts\Activate.ps1

4. Install dependencies

pip install -r requirements.txt

This installs fastmcp>=2.0, pydantic>=2.0, and pytest>=8.0.


5. Configuration

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.

Example: expanding the allowed roots

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 causes PATH_VIOLATION errors.

Linux / macOS (bash/zsh):

export MCP_ALLOWED_ROOTS="/home/user/project/data:/tmp/mcp-scratch"
fastmcp run mcp_server/server.py

Windows (PowerShell) — note the semicolon separator:

$env:MCP_ALLOWED_ROOTS = "C:\Workspace\project\data;C:\Temp\mcp-scratch"
fastmcp run mcp_server/server.py

Example: raising the file size limit to 10 MiB

export MCP_MAX_FILE_SIZE_BYTES=10485760

Note: If MCP_ALLOWED_ROOTS is not set, the server defaults to <project_root>/data (resolved from the location of filesystem.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

6. Running the MCP Server

Two start commands are supported. Both start the server in stdio transport mode, ready to accept MCP requests.

Option A — FastMCP CLI (recommended)

fastmcp run mcp_server/server.py

FastMCP auto-selects the transport (stdio by default) and handles the server lifecycle.

Option B — Python module invocation

python -m mcp_server

Expected startup behaviour

The 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", ... }
  ]
}

Quick verification with the FastMCP client

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

7. Exposed Tools

7.1 run_simulation

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.

Input Parameters

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

Success Response

{
  "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
  }
}

Error Response

{
  "ok": false,
  "error": "steps must be between 1 and 100"
}

7.2 get_state

Returns the current simulation state. Read-only — calling this tool never modifies state. Safe to call multiple times in succession.

Input Parameters

None.

Success Response

{
  "ok": true,
  "state": {
    "step": 5,
    "value": 5.0,
    "history": [1.0, 2.0, 3.0, 4.0, 5.0]
  }
}

7.3 read_write_file

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.

Input Parameters

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

Success Response — read

{
  "ok": true,
  "operation": "read",
  "path": "/absolute/path/to/data/results.json",
  "content": "{\"x\": 1}",
  "size_bytes": 8
}

Success Response — write

{
  "ok": true,
  "operation": "write",
  "path": "/absolute/path/to/data/output.txt",
  "size_bytes": 5
}

Error Response

{
  "ok": false,
  "error": "path is outside allowed roots",
  "error_code": "PATH_VIOLATION"
}

Error Codes

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

8. Usage Examples

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.

8.1 Advancing the simulation

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


8.2 Running with a custom input value

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
  }
}

8.3 Resetting then running

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
  }
}

8.4 Querying state without side effects

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.


8.5 Writing and reading a file (round-trip)

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


9. Error Handling

All recoverable errors return a consistent envelope. Raw Python tracebacks are never surfaced to the client.

Error Envelope

{
  "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.

Common Error Scenarios

Path traversal attempt

await client.call_tool("read_write_file", {
    "path": "../../etc/passwd",
    "operation": "read"
})
{
  "ok": false,
  "error": "path is outside allowed roots",
  "error_code": "PATH_VIOLATION"
}

File not found

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

Invalid simulation steps

await client.call_tool("run_simulation", {"steps": 0})
{
  "ok": false,
  "error": "steps must be between 1 and 100"
}

Missing content on write

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

Handling Errors in Client Code

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.


10. Development & Testing

Running the test suite

pytest tests/ -v

Expected 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).

Test modules

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

Manual tool invocation

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())

Running a single test file

pytest tests/test_simulation.py -v

Running tests matching a keyword

pytest tests/ -k "reset" -v

11. Limitations and Notes

In-memory state only

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

Filesystem sandbox

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.

No authentication or authorisation

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.

Single process, single-threaded

The server runs in a single Python process. There is no multi-process state sharing. All tools share one SimulationState instance within that process.

File size limit

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.

About

A local, self-contained MCP (Model Context Protocol) server implemented in Python using [FastMCP](https://github.com/jlowin/fastmcp). It exposes three callable tools over stdio transport that any MCP-compatible client (CLI or Python script) can invoke.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages