Skip to main content
Glama
nhannt95

Production MCP Server

by nhannt95
README.md
# Production MCP Server

> πŸ‡»πŸ‡³ **HΖ°α»›ng dαΊ«n tiαΊΏng Việt:** [HUONG_DAN.md](HUONG_DAN.md) Β· ThΓͺm tool: [THEM_TOOL.md](THEM_TOOL.md) Β· Prompts: [THEM_PROMPT.md](THEM_PROMPT.md) Β· Nhiều client (HTTP/SSE): [CHAY_NHIEU_CLIENT.md](CHAY_NHIEU_CLIENT.md) Β· Agent LangChain/CrewAI: [KET_NOI_AGENT.md](KET_NOI_AGENT.md) Β· CΖ‘ sở dα»― liệu: [KET_NOI_DATABASE.md](KET_NOI_DATABASE.md)

A production-ready, modular **Model Context Protocol (MCP)** server built with
**FastMCP**. It ships **40+ tools** across 8 categories and is designed to scale
to **300+ tools without modifying any existing code** β€” you just drop a new file
into `tools/<category>/` and it registers itself on startup.

## Highlights

- **Clean architecture & SOLID** β€” tools stay thin; logic lives in `services/`; cross-cutting concerns live in `core/`.
- **Dynamic auto-loading** β€” the loader scans `tools/**` (and `prompts/**`) and calls each module's `register(mcp)`. No manual imports, ever.
- **Prompts too** β€” 12 reusable MCP prompts (SQL Q&A, code review, summarize, system triage…) auto-loaded the same way. See [THEM_PROMPT.md](THEM_PROMPT.md).
- **Standard tool template** β€” every tool has a description, input validation, error handling, logging, type hints and a docstring.
- **Uniform responses** β€” every tool returns `{ success, data, error }`.
- **Security by default** β€” filesystem sandbox, SSRF guard on HTTP, secret redaction, write-guarded SQL.
- **Async everywhere** β€” `httpx`, `aiofiles`, and threads for blocking libs (`sqlite3`, `openpyxl`).
- **Typed config** via `.env` (pydantic-settings) and structured logging.
- **Local AI** β€” AI tools call your **local model over an OpenAI-compatible API (vLLM)**, with an offline heuristic fallback so they work with zero config.

---

## Project structure

```
mcp-server/
β”œβ”€β”€ main.py                # Entrypoint: configures logging, runs the transport
β”œβ”€β”€ server.py              # Builds the FastMCP instance + runs the loader
β”œβ”€β”€ config.py              # Typed settings from .env (pydantic-settings)
β”œβ”€β”€ requirements.txt
β”œβ”€β”€ pytest.ini
β”œβ”€β”€ .env.example
β”‚
β”œβ”€β”€ core/                  # Reusable infrastructure
β”‚   β”œβ”€β”€ logger.py          # Logging (stderr-safe for stdio), JSON option
β”‚   β”œβ”€β”€ exceptions.py      # Domain exception hierarchy
β”‚   β”œβ”€β”€ decorators.py      # @tool_handler: logging + timing + error mapping
β”‚   β”œβ”€β”€ response.py        # make_response() / make_error() envelopes
β”‚   β”œβ”€β”€ utils.py           # validate_path/validate_url/safe_read/safe_write
β”‚   └── loader.py          # Dynamic tool discovery & registration
β”‚
β”œβ”€β”€ services/              # Stateful / IO logic (reused by tools)
β”‚   β”œβ”€β”€ http_service.py    # Shared httpx client + SSRF policy
β”‚   β”œβ”€β”€ database_service.py# Pluggable DB drivers (SQLite now; PG/MySQL later)
β”‚   β”œβ”€β”€ ai_service.py      # OpenAI-compatible (vLLM) chat client
β”‚   └── text_heuristics.py # Offline fallback for AI tools
β”‚
β”œβ”€β”€ models/                # Pydantic models (typed tool outputs)
β”‚   └── common.py
β”‚
β”œβ”€β”€ tools/                 # One folder per category, one file per tool
β”‚   β”œβ”€β”€ system/            # get_system_info, get_os, get_hostname, get_cpu,
β”‚   β”‚                      #   get_memory, get_disk, get_env, current_time
β”‚   β”œβ”€β”€ filesystem/        # read/write/append/delete/move/copy/rename files,
β”‚   β”‚                      #   create/delete/list directory, search, file_info
β”‚   β”œβ”€β”€ api/               # http_get, http_post, http_put, http_delete
β”‚   β”œβ”€β”€ network/           # dns_lookup (extend freely)
β”‚   β”œβ”€β”€ database/          # execute_query, list_tables, describe_table
β”‚   β”œβ”€β”€ utilities/         # base64, uuid, md5, sha256, json_format, yaml<->json
β”‚   β”œβ”€β”€ ai/                # count_tokens, summarize_text, extract_keywords
β”‚   └── office/            # csv_reader/writer, excel_reader/writer
β”‚
β”œβ”€β”€ tests/                 # pytest suite (loader, core, ~2 tools/category)
└── examples/              # Example client + Claude/VSCode/Cursor/Windsurf configs
```

---

## Quick start

```bash
# 1. Create and activate a virtual environment (Python 3.12+; 3.11 also works)
python -m venv .venv
.\.venv\Scripts\activate          # Windows PowerShell
# source .venv/bin/activate         # macOS / Linux

# 2. Install dependencies
pip install -r requirements.txt

# 3. Configure
copy .env.example .env             # Windows  (cp on macOS/Linux)
#   edit .env β€” at minimum set MCP_ALLOWED_ROOT to your working folder

# 4. Run the server (stdio transport by default)
python main.py

# 5. (optional) Run the tests
pytest
```

To run over HTTP instead of stdio, set `MCP_TRANSPORT=http` (and `MCP_HOST` /
`MCP_PORT`) in `.env`, then `python main.py`.

---

## Trying it out with the example client

The in-memory client needs no running server β€” it imports the server object
directly:

```bash
python examples/mcp_client.py           # in-memory (fastest)
python examples/mcp_client.py --stdio   # spawns `python main.py` over stdio
```

---

## Connecting from an IDE / desktop client

Ready-to-edit configs live in `examples/`. Replace the absolute paths with your
own (point `command` at your venv's Python and `args` at `main.py`).

| Client          | File to edit                                                     | Example                              |
|-----------------|------------------------------------------------------------------|--------------------------------------|
| Claude Desktop  | `%APPDATA%\Claude\claude_desktop_config.json` (Win) / `~/Library/Application Support/Claude/…` (mac) | `examples/claude_desktop_config.json` |
| VSCode          | `.vscode/mcp.json`                                               | `examples/vscode_mcp.json`           |
| Cursor          | `.cursor/mcp.json` or `~/.cursor/mcp.json`                       | `examples/cursor_mcp.json`           |
| Windsurf        | `~/.codeium/windsurf/mcp_config.json`                            | `examples/windsurf_mcp.json`         |

After editing, restart the client. The server's tools appear in the client's
tool list.

---

## Configuration reference (`.env`)

All variables are prefixed `MCP_`. See `.env.example` for the full list. Key ones:

| Variable                     | Default                     | Purpose                                            |
|------------------------------|-----------------------------|----------------------------------------------------|
| `MCP_TRANSPORT`              | `stdio`                     | `stdio` \| `http` \| `sse`                          |
| `MCP_LOG_LEVEL`              | `INFO`                      | Logging verbosity                                  |
| `MCP_ALLOWED_ROOT`           | current working dir         | **Filesystem sandbox root** β€” tools cannot escape it |
| `MCP_HTTP_ALLOW_PRIVATE_HOSTS` | `false`                   | Keep `false` to block SSRF to private/loopback IPs |
| `MCP_DATABASE_URL`           | `sqlite:///./data/app.db`   | DB connection (SQLite today)                        |
| `MCP_DB_ALLOW_WRITE`         | `true`                      | Allow write/DDL SQL statements                     |
| `MCP_OPENAI_BASE_URL`        | *(unset)*                   | vLLM OpenAI-compatible URL, e.g. `http://localhost:8001/v1` |
| `MCP_OPENAI_MODEL`           | *(unset)*                   | Model name served by vLLM                          |

---

## Tool categories (40+ tools)

Every tool returns the same envelope:

```json
{ "success": true, "data": { ... }, "error": null }
```

...and on failure:

```json
{ "success": false, "data": null, "error": { "code": "validation_error", "message": "...", "details": {} } }
```

- **System** β€” `get_system_info`, `get_os`, `get_hostname`, `get_cpu`, `get_memory`, `get_disk`, `get_env`, `current_time`
- **File System** *(sandboxed)* β€” `read_file`, `write_file`, `append_file`, `delete_file`, `move_file`, `copy_file`, `rename_file`, `create_directory`, `delete_directory`, `list_directory`, `search_files`, `file_info`
- **API** *(SSRF-guarded, httpx)* β€” `http_get`, `http_post`, `http_put`, `http_delete`
- **Network** β€” `dns_lookup`
- **Database** *(SQLite; PG/MySQL-ready)* β€” `execute_query`, `list_tables`, `describe_table`
- **Utilities** β€” `base64_encode`, `base64_decode`, `uuid`, `md5`, `sha256`, `json_format`, `yaml_to_json`, `json_to_yaml`
- **AI** *(local vLLM or heuristic fallback)* β€” `count_tokens`, `summarize_text`, `extract_keywords`
- **Office** β€” `csv_reader`, `csv_writer`, `excel_reader`, `excel_writer`

---

## Using the local AI (vLLM) tools

The AI tools call a **local model through an OpenAI-compatible API**. Start vLLM:

```bash
python -m vllm.entrypoints.openai.api_server \
    --model Qwen/Qwen2.5-7B-Instruct \
    --port 8001
```

Then set in `.env`:

```env
MCP_OPENAI_BASE_URL=http://localhost:8001/v1
MCP_OPENAI_API_KEY=EMPTY
MCP_OPENAI_MODEL=Qwen/Qwen2.5-7B-Instruct
```

- With these set, `summarize_text` and `extract_keywords` use the LLM (`"backend": "llm"`).
- **Without** them, they fall back to a fast local heuristic (`"backend": "heuristic"`) β€” so the tools work out of the box with no model running.
- `count_tokens` always runs locally via `tiktoken`.

---

## How the dynamic loader works

On startup, [`core/loader.py`](core/loader.py) walks the `tools` package
recursively, imports every non-private module, and calls its `register(mcp)`
function. A broken tool is logged and skipped β€” it never takes down the server.
That single convention is what makes the project scale to 300+ tools with **zero
edits to existing files**.

---

## Add a new tool in under a minute

See [`ADD_A_TOOL.md`](ADD_A_TOOL.md) for the copy-paste template. In short:

1. Create `tools/<category>/my_tool.py`.
2. Paste the template, rename the function, write your logic.
3. Restart the server β€” done. It auto-registers.

```python
# tools/utilities/reverse_text.py
from __future__ import annotations
from typing import TYPE_CHECKING
from core.decorators import tool_handler

if TYPE_CHECKING:
    from fastmcp import FastMCP

def register(mcp: "FastMCP") -> None:
    @mcp.tool()
    @tool_handler
    async def reverse_text(text: str) -> dict:
        """Reverse a string."""
        return {"reversed": text[::-1]}
```

No imports to update, no registry to edit. That's the whole workflow.

---

## Extending the database to PostgreSQL / MySQL

The database layer is built for this. In
[`services/database_service.py`](services/database_service.py):

1. Implement a class satisfying the `DatabaseDriver` protocol (`execute`,
   `list_tables`, `describe_table`).
2. Register it in the `_DRIVERS` dict by URL scheme.
3. Point `MCP_DATABASE_URL` at the new backend.

The three database **tools do not change** β€” they depend on the abstract driver.

---

## Docker

The image defaults to the **HTTP transport** (stdio can't work in a container)
and exposes port `8000`.

```bash
# Compose (recommended) β€” build, run, persist data in a volume
docker compose up --build

# Or plain Docker
docker build -t production-mcp-server:latest .
docker run -d --name mcp-server -p 8000:8000 -v mcp-data:/data \
  production-mcp-server:latest
```

The server is then reachable at `http://localhost:8000/mcp`. It runs as a
non-root user, and SQLite + the filesystem-tool workspace live under `/data`
(mounted as a volume). To wire in a local vLLM model, uncomment the `vllm`
service in `docker-compose.yml` and set the `MCP_OPENAI_*` vars.

---

## Testing

```bash
pytest            # runs the full suite
pytest -v         # verbose
```

The suite covers the dynamic loader, core helpers (response envelope, path/URL
validation, the `@tool_handler` decorator), and 1–2 representative tools per
category exercised end-to-end.

---

## Security notes

- **Filesystem** tools resolve every path and reject anything outside `MCP_ALLOWED_ROOT` (blocks `../` traversal).
- **HTTP** tools reject non-http(s) schemes and, by default, any host resolving to a private/loopback/link-local address (SSRF guard). Toggle with `MCP_HTTP_ALLOW_PRIVATE_HOSTS`.
- **`get_env`** redacts values whose names look secret (`*key*`, `*token*`, `*password*`, …).
- **SQL** write/DDL statements are blocked unless `MCP_DB_ALLOW_WRITE=true`; use parameterised queries (`?`).
- Logs go to **stderr** so they never corrupt the stdio JSON-RPC stream.

---

## License

MIT (or your organisation's preferred license).