Анатомия production-сервера: что отличает его от учебного примера

В уроке «Что такое MCP» мы написали сервер за 20 строк — он принимал имя и возвращал приветствие. Реальный сервер решает три задачи, которых в учебном примере нет:

  • Схемы с валидацией — каждый инструмент описывает свои параметры через JSON Schema, включая типы, ограничения и обязательные поля. LLM читает эту схему и знает, что передавать.
  • Обработка ошибок — инструмент должен вернуть структурированную ошибку, а не упасть с исключением. Исключение ломает transport, структурированная ошибка даёт LLM шанс исправить вызов.
  • Безопасность — файловые инструменты без ограничений позволяют читать /etc/passwd; SQL-инструменты без параметризации открывают инъекции. Реальный сервер проектирует границы явно.

Три группы инструментов, которые покрывают большинство реальных агентских задач:

📁
Файловая система
read_file
write_file
list_directory
search_files
get_file_info
🗄
База данных
query_db
execute_db
list_tables
describe_table
explain_query
🌐
Внешний API
http_get
http_post
fetch_json
search_web
get_page_text
100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
LLM Agent tools/call resources/read prompts/get MCP Client ClientSession Future map stdio/SSE MCP Server ROUTER dispatch(method, params) tools/list · tools/call resources/list · resources/read prompts/list · prompts/get TOOL REGISTRY read_file · query_db · http_get ··· ErrorHandler · RateLimiter config · allowed_paths · db_url File Tools read_file write_file list_directory · search_files sandbox: allowed_paths DB Tools query_db · execute_db list_tables · describe_table SQLite / PostgreSQL parameterized queries only API Tools http_get · http_post fetch_json httpx · rate limit · timeout Filesystem SQLite / Postgres External API

Все три группы регистрируются в одном экземпляре Server. Роутер сервера видит инструменты как плоский реестр по имени и вызывает нужный обработчик. Разделение на группы — только в нашем коде.

Схема инструмента: как LLM понимает что передавать

Прежде чем писать файловые инструменты — разберём, как устроена схема. Именно inputSchema говорит LLM: «вот параметры, вот их типы, вот что обязательно». Если схема неточная — LLM будет угадывать.

Анатомия types.Tool — каждое поле имеет значение
namereq
Уникальный идентификатор. Используется при tools/call. Только латиница, цифры, дефис, подчёркивание. Никакого camelCase — LLM лучше читает read_file, чем readFile.
descriptionreq
Описание для LLM, не для человека. Пиши что инструмент делает, когда его нужно использовать и чего он не умеет. Одна плохая description = LLM вызывает не тот инструмент или не вызывает нужный.
inputSchemareq
JSON Schema объекта с параметрами. Должна содержать type: "object", properties с типами каждого параметра, required список обязательных, description на каждое поле. Без description на поля LLM снова угадывает.

Пример правильно описанного инструмента:

types.Tool(
    name="read_file",
    description=(
        "Читает содержимое файла по указанному пути. "
        "Используй когда нужно получить содержимое конкретного файла. "
        "Не подходит для поиска по файлам — используй search_files. "
        "Возвращает текст файла в кодировке UTF-8."
    ),
    inputSchema={
        "type": "object",
        "properties": {
            "path": {
                "type": "string",
                "description": "Абсолютный или относительный путь к файлу"
            },
            "encoding": {
                "type": "string",
                "description": "Кодировка файла, по умолчанию utf-8",
                "default": "utf-8",
                "enum": ["utf-8", "latin-1", "cp1251"]
            }
        },
        "required": ["path"]
    }
)
Правило description: хорошая description содержит три части — что делает, когда использовать, что не умеет. LLM читает её как документацию при выборе инструмента.

Файловая система: чтение, запись, поиск

Файловые инструменты — самые популярные и самые опасные. Без ограничений агент может прочитать любой файл в системе, включая секреты. Sandboxing через allowed_paths — обязательная часть реализации, не опциональная.

Принцип работы sandbox: при инициализации сервер получает список разрешённых директорий. Перед каждой операцией путь резолвится через Path.resolve() и проверяется, что он находится внутри одной из разрешённых директорий.

"""
Файловые инструменты для MCP-сервера.
pip install mcp
"""
import fnmatch
import logging
from pathlib import Path
from mcp import types

logger = logging.getLogger(__name__)


class FileToolset:
    """Группа файловых инструментов с sandbox-ограничением."""

    def __init__(self, allowed_paths: list[str]):
        # Резолвим пути сразу, чтобы не делать это при каждом вызове
        self.allowed = [Path(p).resolve() for p in allowed_paths]

    # ── Внутренние методы ──────────────────────────────────────────────

    def _check_path(self, path: str) -> Path:
        """Проверяет, что путь находится в разрешённой зоне."""
        resolved = Path(path).resolve()
        for allowed in self.allowed:
            try:
                resolved.relative_to(allowed)
                return resolved          # путь разрешён
            except ValueError:
                continue
        raise PermissionError(
            f"Путь '{path}' находится вне разрешённых директорий: "
            + ", ".join(str(a) for a in self.allowed)
        )

    def _ok(self, text: str) -> list[types.TextContent]:
        return [types.TextContent(type="text", text=text)]

    def _err(self, msg: str) -> list[types.TextContent]:
        logger.warning("FileToolset error: %s", msg)
        return [types.TextContent(type="text", text=f"[ERROR] {msg}")]

    # ── Инструменты ───────────────────────────────────────────────────

    async def read_file(self, path: str, encoding: str = "utf-8") -> list[types.TextContent]:
        try:
            p = self._check_path(path)
            if not p.is_file():
                return self._err(f"'{path}' не является файлом")
            content = p.read_text(encoding=encoding)
            logger.info("read_file: %s (%d bytes)", path, len(content))
            return self._ok(content)
        except PermissionError as e:
            return self._err(str(e))
        except Exception as e:
            return self._err(f"Ошибка чтения: {e}")

    async def write_file(self, path: str, content: str, encoding: str = "utf-8") -> list[types.TextContent]:
        try:
            p = self._check_path(path)
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_text(content, encoding=encoding)
            return self._ok(f"Записано {len(content)} символов → {path}")
        except PermissionError as e:
            return self._err(str(e))
        except Exception as e:
            return self._err(f"Ошибка записи: {e}")

    async def list_directory(self, path: str) -> list[types.TextContent]:
        try:
            p = self._check_path(path)
            if not p.is_dir():
                return self._err(f"'{path}' не является директорией")
            entries = []
            for item in sorted(p.iterdir()):
                prefix = "📁 " if item.is_dir() else "📄 "
                size = f"  ({item.stat().st_size} bytes)" if item.is_file() else ""
                entries.append(f"{prefix}{item.name}{size}")
            return self._ok("\n".join(entries) if entries else "(пустая директория)")
        except PermissionError as e:
            return self._err(str(e))

    async def search_files(self, path: str, pattern: str) -> list[types.TextContent]:
        """Рекурсивный поиск файлов по glob-паттерну."""
        try:
            base = self._check_path(path)
            if not base.is_dir():
                return self._err(f"'{path}' не является директорией")
            matches = [
                str(f.relative_to(base))
                for f in base.rglob("*")
                if f.is_file() and fnmatch.fnmatch(f.name, pattern)
            ]
            if not matches:
                return self._ok(f"Файлы по паттерну '{pattern}' не найдены")
            return self._ok(f"Найдено {len(matches)} файлов:\n" + "\n".join(matches))
        except PermissionError as e:
            return self._err(str(e))

    # ── Описания инструментов ─────────────────────────────────────────

    def get_tools(self) -> list[types.Tool]:
        return [
            types.Tool(
                name="read_file",
                description="Читает содержимое файла по пути. Возвращает текст в UTF-8.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "path": {"type": "string", "description": "Путь к файлу"},
                        "encoding": {"type": "string", "description": "Кодировка", "default": "utf-8"}
                    },
                    "required": ["path"]
                }
            ),
            types.Tool(
                name="write_file",
                description="Записывает текст в файл. Создаёт директории при необходимости.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "path": {"type": "string", "description": "Путь к файлу"},
                        "content": {"type": "string", "description": "Текст для записи"}
                    },
                    "required": ["path", "content"]
                }
            ),
            types.Tool(
                name="list_directory",
                description="Показывает содержимое директории (не рекурсивно).",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "path": {"type": "string", "description": "Путь к директории"}
                    },
                    "required": ["path"]
                }
            ),
            types.Tool(
                name="search_files",
                description="Рекурсивно ищет файлы по glob-паттерну (например '*.py', '*.log').",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "path": {"type": "string", "description": "Базовая директория для поиска"},
                        "pattern": {"type": "string", "description": "Glob-паттерн, например '*.py'"}
                    },
                    "required": ["path", "pattern"]
                }
            ),
        ]
Path traversal: классическая атака. Путь ../../etc/passwd после resolve() становится /etc/passwd. Всегда вызывай Path.resolve() до проверки и сравнивай с allowed.resolve(). Строковые prefix-сравнения ненадёжны: /data/secret начинается с /data, но это другой путь.

База данных: SQLite и PostgreSQL

База данных — самый мощный инструмент для агента и самый опасный с точки зрения безопасности. Есть два вида операций, которые нужно разделять явно:

  • SELECT (только чтение) — безопасно, но требует защиты от инъекций через параметризацию;
  • INSERT/UPDATE/DELETE (запись) — опасно, требует явного разрешения и лимитов на количество затронутых строк.

Реализуем оба — сначала для SQLite (sync через aiosqlite), потом покажем адаптацию под PostgreSQL через asyncpg.

"""
Инструменты для работы с SQLite через MCP.
pip install mcp aiosqlite
"""
import json
import logging
import aiosqlite
from mcp import types

logger = logging.getLogger(__name__)


class SQLiteToolset:
    """
    Инструменты для SQLite с разделением read/write.
    allow_writes=False — сервер в режиме только для чтения.
    """

    def __init__(self, db_path: str, allow_writes: bool = False):
        self.db_path = db_path
        self.allow_writes = allow_writes

    async def query_db(self, sql: str, params: list | None = None) -> list[types.TextContent]:
        """Выполняет SELECT-запрос и возвращает результат в JSON."""
        sql_upper = sql.strip().upper()
        # Разрешаем только SELECT и WITH (CTE)
        if not (sql_upper.startswith("SELECT") or sql_upper.startswith("WITH")):
            return [types.TextContent(type="text", text="[ERROR] query_db принимает только SELECT/WITH. Для изменений используй execute_db.")]

        try:
            async with aiosqlite.connect(self.db_path) as db:
                db.row_factory = aiosqlite.Row
                cursor = await db.execute(sql, params or [])
                rows = await cursor.fetchmany(500)  # Лимит на количество строк
                if not rows:
                    return [types.TextContent(type="text", text="(запрос вернул 0 строк)")]

                data = [dict(row) for row in rows]
                result = json.dumps(data, ensure_ascii=False, indent=2, default=str)
                note = f"\n\n(показано {len(data)} строк)" if len(data) == 500 else ""
                return [types.TextContent(type="text", text=result + note)]
        except Exception as e:
            logger.error("query_db error: %s | sql: %s", e, sql[:200])
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]

    async def execute_db(self, sql: str, params: list | None = None) -> list[types.TextContent]:
        """Выполняет INSERT/UPDATE/DELETE. Требует allow_writes=True."""
        if not self.allow_writes:
            return [types.TextContent(type="text", text="[ERROR] Сервер настроен в режиме только для чтения.")]

        sql_upper = sql.strip().upper()
        # Запрещаем DDL и DROP
        for dangerous in ("DROP ", "TRUNCATE ", "CREATE ", "ALTER ", "ATTACH ", "DETACH "):
            if sql_upper.startswith(dangerous):
                return [types.TextContent(type="text", text=f"[ERROR] Операция '{dangerous.strip()}' запрещена через этот инструмент.")]

        try:
            async with aiosqlite.connect(self.db_path) as db:
                cursor = await db.execute(sql, params or [])
                await db.commit()
                return [types.TextContent(type="text", text=f"OK. Затронуто строк: {cursor.rowcount}")]
        except Exception as e:
            logger.error("execute_db error: %s | sql: %s", e, sql[:200])
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]

    async def list_tables(self) -> list[types.TextContent]:
        """Возвращает список таблиц и их базовую информацию."""
        try:
            async with aiosqlite.connect(self.db_path) as db:
                db.row_factory = aiosqlite.Row
                cursor = await db.execute(
                    "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name"
                )
                rows = await cursor.fetchall()
                if not rows:
                    return [types.TextContent(type="text", text="(база данных пустая)")]
                lines = [f"[{row['type']}] {row['name']}" for row in rows]
                return [types.TextContent(type="text", text="\n".join(lines))]
        except Exception as e:
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]

    async def describe_table(self, table: str) -> list[types.TextContent]:
        """Возвращает схему таблицы: колонки, типы, nullable, PK."""
        try:
            async with aiosqlite.connect(self.db_path) as db:
                db.row_factory = aiosqlite.Row
                cursor = await db.execute(f"PRAGMA table_info('{table}')")
                cols = await cursor.fetchall()
                if not cols:
                    return [types.TextContent(type="text", text=f"Таблица '{table}' не найдена")]

                lines = [f"Таблица: {table}", "─" * 40]
                for col in cols:
                    pk = " [PK]" if col["pk"] else ""
                    nn = " NOT NULL" if col["notnull"] else ""
                    dflt = f" DEFAULT {col['dflt_value']}" if col["dflt_value"] else ""
                    lines.append(f"  {col['name']}: {col['type']}{pk}{nn}{dflt}")
                return [types.TextContent(type="text", text="\n".join(lines))]
        except Exception as e:
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]

    def get_tools(self) -> list[types.Tool]:
        tools = [
            types.Tool(
                name="query_db",
                description="Выполняет SELECT-запрос к базе данных. Возвращает JSON-массив строк. Максимум 500 строк.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "sql": {"type": "string", "description": "SELECT-запрос"},
                        "params": {"type": "array", "description": "Параметры запроса (список значений для ?)", "items": {}}
                    },
                    "required": ["sql"]
                }
            ),
            types.Tool(
                name="list_tables",
                description="Возвращает список таблиц и представлений в базе данных.",
                inputSchema={"type": "object", "properties": {}, "required": []}
            ),
            types.Tool(
                name="describe_table",
                description="Показывает схему таблицы: колонки, типы, ограничения.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "table": {"type": "string", "description": "Название таблицы"}
                    },
                    "required": ["table"]
                }
            ),
        ]
        if self.allow_writes:
            tools.append(types.Tool(
                name="execute_db",
                description="Выполняет INSERT/UPDATE/DELETE запрос. Не поддерживает DDL (CREATE/DROP/ALTER).",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "sql": {"type": "string", "description": "INSERT/UPDATE/DELETE запрос"},
                        "params": {"type": "array", "description": "Параметры запроса", "items": {}}
                    },
                    "required": ["sql"]
                }
            ))
        return tools
PostgreSQL через asyncpg. Замени aiosqlite.connect на asyncpg.create_pool(dsn=...) и получи connection pooling. Параметризованные запросы в asyncpg используют $1, $2 вместо ?, поэтому в execute_db нужно добавить замену placeholder'ов или принимать $N-синтаксис явно.

Внешний API: httpx, таймауты, ограничения

HTTP-инструменты превращают агента в интернет-клиент. Ключевые требования production-версии:

  • Таймаут обязателен. Без него зависший сервер подвесит всю цепочку.
  • Whitelist доменов. Без ограничений агент может делать запросы куда угодно, включая внутреннюю сеть (SSRF).
  • API-ключи в конфиге, не в коде. Передаём через переменные окружения или конфиг-файл.
"""
HTTP-инструменты для MCP-сервера.
pip install mcp httpx
"""
import json
import logging
from urllib.parse import urlparse
import httpx
from mcp import types

logger = logging.getLogger(__name__)

DEFAULT_HEADERS = {
    "User-Agent": "MCP-Agent/1.0",
    "Accept": "application/json, text/plain, */*",
}


class APIToolset:
    """
    HTTP-инструменты с whitelist доменов и таймаутами.
    allowed_domains=None означает запрет всех запросов.
    allowed_domains=["*"] — разрешить всё (только для dev).
    """

    def __init__(
        self,
        allowed_domains: list[str] | None = None,
        timeout: float = 15.0,
        extra_headers: dict | None = None,
    ):
        self.allowed_domains = allowed_domains or []
        self.timeout = httpx.Timeout(timeout)
        self.headers = {**DEFAULT_HEADERS, **(extra_headers or {})}

    def _check_url(self, url: str) -> str:
        """Проверяет домен по whitelist."""
        if "*" in self.allowed_domains:
            return url
        parsed = urlparse(url)
        domain = parsed.netloc.lower()
        for allowed in self.allowed_domains:
            if domain == allowed or domain.endswith(f".{allowed}"):
                return url
        raise PermissionError(
            f"Домен '{domain}' не разрешён. Разрешены: {self.allowed_domains}"
        )

    def _truncate(self, text: str, limit: int = 8000) -> str:
        if len(text) <= limit:
            return text
        return text[:limit] + f"\n\n[...обрезано, всего {len(text)} символов]"

    async def http_get(self, url: str, headers: dict | None = None) -> list[types.TextContent]:
        try:
            self._check_url(url)
            async with httpx.AsyncClient(timeout=self.timeout) as client:
                resp = await client.get(
                    url,
                    headers={**self.headers, **(headers or {})},
                    follow_redirects=True,
                )
                resp.raise_for_status()
                return [types.TextContent(type="text", text=self._truncate(resp.text))]
        except PermissionError as e:
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]
        except httpx.TimeoutException:
            return [types.TextContent(type="text", text=f"[ERROR] Таймаут запроса к {url}")]
        except httpx.HTTPStatusError as e:
            return [types.TextContent(type="text", text=f"[ERROR] HTTP {e.response.status_code}: {url}")]
        except Exception as e:
            logger.error("http_get error: %s | url: %s", e, url)
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]

    async def fetch_json(self, url: str, headers: dict | None = None) -> list[types.TextContent]:
        """Получает JSON и форматирует его читаемо."""
        try:
            self._check_url(url)
            async with httpx.AsyncClient(timeout=self.timeout) as client:
                resp = await client.get(
                    url,
                    headers={**self.headers, **(headers or {})},
                    follow_redirects=True,
                )
                resp.raise_for_status()
                data = resp.json()
                formatted = json.dumps(data, ensure_ascii=False, indent=2)
                return [types.TextContent(type="text", text=self._truncate(formatted))]
        except PermissionError as e:
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]
        except Exception as e:
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]

    async def http_post(
        self,
        url: str,
        body: dict | None = None,
        headers: dict | None = None,
    ) -> list[types.TextContent]:
        try:
            self._check_url(url)
            async with httpx.AsyncClient(timeout=self.timeout) as client:
                resp = await client.post(
                    url,
                    json=body,
                    headers={**self.headers, **(headers or {})},
                )
                resp.raise_for_status()
                return [types.TextContent(type="text", text=self._truncate(resp.text))]
        except PermissionError as e:
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]
        except Exception as e:
            return [types.TextContent(type="text", text=f"[ERROR] {e}")]

    def get_tools(self) -> list[types.Tool]:
        return [
            types.Tool(
                name="http_get",
                description="Выполняет GET-запрос и возвращает тело ответа. Для JSON лучше использовать fetch_json.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "url":     {"type": "string", "description": "URL запроса"},
                        "headers": {"type": "object", "description": "Дополнительные заголовки"}
                    },
                    "required": ["url"]
                }
            ),
            types.Tool(
                name="fetch_json",
                description="Получает JSON по URL и форматирует его для удобного чтения.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "url":     {"type": "string", "description": "URL JSON-эндпоинта"},
                        "headers": {"type": "object", "description": "Дополнительные заголовки"}
                    },
                    "required": ["url"]
                }
            ),
            types.Tool(
                name="http_post",
                description="Выполняет POST-запрос с JSON-телом.",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "url":     {"type": "string", "description": "URL эндпоинта"},
                        "body":    {"type": "object", "description": "JSON-тело запроса"},
                        "headers": {"type": "object", "description": "Дополнительные заголовки"}
                    },
                    "required": ["url"]
                }
            ),
        ]

Resources: данные без вызова инструмента

Tools — это действия. Resources — это данные, которые клиент может запросить напрямую, без tool call. Разница принципиальная: LLM не решает «вызвать ли инструмент», клиент просто подгружает ресурс в контекст при инициализации.

Аспект Tool Resource
Кто инициирует LLM решает вызвать инструмент Клиент подгружает явно или автоматически
Когда использовать Действие с побочным эффектом, вычисление Статичные данные: схема БД, конфиг, документация
Формат URI Нет URI file:///path, db://table, кастомный
Обновление При каждом вызове Можно подписаться на resources/updated

Типичный пример ресурса — схема базы данных. Подгружается в системный промпт один раз при старте, а не при каждом запросе.

from mcp.server import Server
from mcp import types

server = Server("my-server")


@server.list_resources()
async def list_resources() -> list[types.Resource]:
    """Объявляем доступные ресурсы."""
    return [
        types.Resource(
            uri="db://schema",
            name="Database Schema",
            description="Схема всех таблиц базы данных в текстовом формате",
            mimeType="text/plain",
        ),
        types.Resource(
            uri="file:///project/README.md",
            name="Project README",
            description="Документация проекта",
            mimeType="text/markdown",
        ),
    ]


@server.read_resource()
async def read_resource(uri: str) -> str:
    """Возвращает содержимое ресурса по URI."""
    if uri == "db://schema":
        # Например, читаем схему из SQLite
        import aiosqlite
        async with aiosqlite.connect("app.db") as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
            )
            tables = [row["name"] for row in await cursor.fetchall()]
            lines = []
            for table in tables:
                c = await db.execute(f"PRAGMA table_info('{table}')")
                cols = await c.fetchall()
                col_strs = ", ".join(f"{col['name']} {col['type']}" for col in cols)
                lines.append(f"{table}({col_strs})")
            return "\n".join(lines)

    if uri.startswith("file://"):
        path = uri[7:]
        return open(path, encoding="utf-8").read()

    raise ValueError(f"Unknown resource URI: {uri}")
Resources vs Tools: практическое правило. Если данные не меняются между запросами — ресурс. Если зависят от параметров или имеют побочный эффект — инструмент. Схема БД — ресурс. Результат SELECT с фильтром — инструмент.

Собираем всё вместе: multi-capability сервер

Теперь объединяем все три toolset'а в один сервер с конфигурацией через датакласс. Конфигурация через датакласс — не архитектурный оверкилл: она позволяет создать тестовый сервер с in-memory SQLite и temp-директорией одной строкой.

"""
Production MCP-сервер: файлы + БД + API.
pip install mcp aiosqlite httpx

Запуск: python server.py
"""
import asyncio
import logging
import os
from dataclasses import dataclass, field
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types

# Импорт наших toolset'ов (предполагаем, что они в отдельных файлах)
from file_tools import FileToolset
from db_tools   import SQLiteToolset
from api_tools  import APIToolset

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(name)s %(levelname)s %(message)s",
    handlers=[logging.FileHandler("mcp_server.log")]
    # ВАЖНО: не добавляем StreamHandler — stdout занят stdio-транспортом
)
logger = logging.getLogger("mcp_server")


@dataclass
class ServerConfig:
    # Файловая система
    allowed_paths: list[str] = field(default_factory=lambda: ["/tmp/agent-workspace"])
    # База данных
    db_path: str = "app.db"
    allow_db_writes: bool = False
    # API
    allowed_domains: list[str] = field(default_factory=lambda: ["api.github.com", "httpbin.org"])
    api_timeout: float = 15.0
    extra_api_headers: dict = field(default_factory=dict)


def build_server(config: ServerConfig) -> Server:
    server = Server("multi-capability-server")

    # Инициализируем toolset'ы
    files = FileToolset(allowed_paths=config.allowed_paths)
    db    = SQLiteToolset(db_path=config.db_path, allow_writes=config.allow_db_writes)
    api   = APIToolset(
        allowed_domains=config.allowed_domains,
        timeout=config.api_timeout,
        extra_headers=config.extra_api_headers,
    )

    # Собираем единый реестр инструментов
    all_tools = files.get_tools() + db.get_tools() + api.get_tools()

    # Диспетчер по имени инструмента
    DISPATCH = {
        "read_file":       lambda a: files.read_file(a["path"], a.get("encoding", "utf-8")),
        "write_file":      lambda a: files.write_file(a["path"], a["content"], a.get("encoding", "utf-8")),
        "list_directory":  lambda a: files.list_directory(a["path"]),
        "search_files":    lambda a: files.search_files(a["path"], a["pattern"]),
        "query_db":        lambda a: db.query_db(a["sql"], a.get("params")),
        "execute_db":      lambda a: db.execute_db(a["sql"], a.get("params")),
        "list_tables":     lambda a: db.list_tables(),
        "describe_table":  lambda a: db.describe_table(a["table"]),
        "http_get":        lambda a: api.http_get(a["url"], a.get("headers")),
        "fetch_json":      lambda a: api.fetch_json(a["url"], a.get("headers")),
        "http_post":       lambda a: api.http_post(a["url"], a.get("body"), a.get("headers")),
    }

    @server.list_tools()
    async def list_tools() -> list[types.Tool]:
        return all_tools

    @server.call_tool()
    async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
        logger.info("tool_call: %s | args_keys: %s", name, list(arguments.keys()))
        handler = DISPATCH.get(name)
        if handler is None:
            return [types.TextContent(type="text", text=f"[ERROR] Инструмент '{name}' не найден")]
        try:
            return await handler(arguments)
        except KeyError as e:
            return [types.TextContent(type="text", text=f"[ERROR] Отсутствует обязательный параметр: {e}")]
        except Exception as e:
            logger.exception("Unexpected error in %s", name)
            return [types.TextContent(type="text", text=f"[ERROR] Внутренняя ошибка сервера: {type(e).__name__}")]

    return server


async def main():
    config = ServerConfig(
        allowed_paths=[
            os.path.expanduser("~/projects"),
            "/tmp/agent-workspace",
        ],
        db_path=os.environ.get("DB_PATH", "app.db"),
        allow_db_writes=os.environ.get("ALLOW_WRITES", "").lower() == "true",
        allowed_domains=os.environ.get("ALLOWED_DOMAINS", "api.github.com").split(","),
    )

    server = build_server(config)
    logger.info("Starting MCP server | tools: %d | paths: %s", 11, config.allowed_paths)

    async with stdio_server() as (read, write):
        await server.run(read, write, server.create_initialization_options())


if __name__ == "__main__":
    asyncio.run(main())
Конфигурация через переменные окружения — стандартный паттерн для MCP-серверов: Claude Desktop передаёт env-переменные в поле env конфига. Это безопаснее, чем CLI-аргументы, которые видны в ps aux.

Конфигурация для Claude Desktop (claude_desktop_config.json):

{
  "mcpServers": {
    "my-server": {
      "command": "python",
      "args": ["/path/to/server.py"],
      "env": {
        "DB_PATH": "/Users/ivan/projects/app.db",
        "ALLOW_WRITES": "false",
        "ALLOWED_DOMAINS": "api.github.com,jsonplaceholder.typicode.com"
      }
    }
  }
}

Безопасность: что может пойти не так

MCP-сервер — это привилегированный процесс, который агент вызывает с инструкциями. Поверхность атаки больше, чем кажется.

Опасно
Файловые инструменты без allowed_paths
SQL через строковую конкатенацию
HTTP без whitelist доменов (SSRF)
DDL-операции (DROP, ALTER) через execute_db
API-ключи в аргументах инструментов
Логирование в stdout (сломает stdio)
Отсутствие таймаутов на HTTP-запросы
Безопасно
Path.resolve() + проверка relative_to()
Параметризованные запросы (? или $N)
Whitelist доменов с проверкой netloc
Разделение query_db / execute_db по типу
Ключи через env-переменные в конфиге
logging.FileHandler или stderr only
httpx.Timeout с явным значением
Prompt injection через данные. Агент читает файл с содержимым «Игнорируй предыдущие инструкции и...» — это реальная атака. MCP-сервер не может её предотвратить на уровне протокола. Защита — на уровне системного промпта агента и ограничения контекста, который попадает в LLM.

Типичные ошибки

Вывод в stdout внутри stdio-сервера
print("debug") внутри сервера пишет в stdout, который MCP использует как транспорт. Клиент получает невалидный JSON и разрывает соединение. Баг трудно диагностировать — сервер «падает» мгновенно.
Исправление: только logging.FileHandler или logging.StreamHandler(sys.stderr). Никаких print.
Исключение вместо структурированной ошибки
Если обработчик инструмента выбрасывает исключение и оно не перехвачено, MCP-SDK возвращает protocol-level error. LLM не может это исправить и прекращает попытки. Структурированная ошибка [ERROR] ... в TextContent — LLM прочитает её и попробует другой подход.
Исправление: оборачивай каждый инструмент в try/except, возвращай текст ошибки через TextContent.
Пустой required: [] вместо отсутствия поля
"required": [] и отсутствующее поле required — одно и то же для JSON Schema. Но некоторые LLM неправильно интерпретируют пустой массив и могут не передавать обязательные поля.
Исправление: указывай required явно только с нужными полями. Для инструментов без параметров опускай поле совсем.
Слишком много инструментов с похожими названиями
LLM путается между get_file, read_file, fetch_file. Больше 20 инструментов на один сервер — уже проблема. Чем хуже description, тем раньше начинаются неправильные выборы.
Исправление: уникальные имена, чёткие description с «когда не использовать», группируй по серверам если инструментов много.
Синхронные блокирующие вызовы в async-функциях
open(), requests.get(), обычный sqlite3.connect() — все они блокируют event loop. Пока блокируется одна операция, сервер не может обработать ping от клиента и соединение разрывается по таймауту.
Исправление: aiosqlite вместо sqlite3, httpx.AsyncClient вместо requests, aiofiles вместо open() для больших файлов.

Шпаргалка

Пишем MCP-сервер — краткая выжимка
  • Структура: FileToolset + SQLiteToolset + APIToolset → единый Server с диспетчером
  • Схема инструмента: name + description (зачем/когда/чего не умеет) + inputSchema (type/properties/required)
  • Файлы: Path.resolve() + relative_to(allowed) → PermissionError если вне зоны
  • БД: только параметризованные запросы; query_db для SELECT, execute_db для DML; DDL запрещён
  • API: whitelist доменов по netloc; httpx.Timeout обязателен; ключи через env
  • Ошибки: try/except внутри каждого инструмента → TextContent с [ERROR]; не пробрасывать исключения
  • Логи: только FileHandler или stderr; никакого stdout в stdio-сервере
  • Resources: статичные данные (схема БД, конфиг) — ресурс; динамика — инструмент
  • Конфиг: allowed_paths, db_path, allowed_domains, allow_writes через env-переменные
  • Async: aiosqlite, httpx.AsyncClient, никаких блокирующих вызовов в async-функциях

Практика

Задание 1. Добавь в FileToolset инструмент get_file_info, который возвращает метаданные файла: размер, дата создания и последней модификации, права доступа (через stat()), является ли файлом или директорией. Результат верни в виде JSON-объекта.
Задание 2. Реализуй простой rate limiter для APIToolset. Добавь параметр max_requests_per_minute: int = 30. Храни очередь временных меток последних запросов и перед каждым вызовом проверяй, не превышен ли лимит. При превышении возвращай [ERROR] Rate limit exceeded. Повторите через N секунд.
Задание 3 (продвинутый). Переделай SQLiteToolset для работы с PostgreSQL через asyncpg. Создай create_pool() при инициализации сервера и переиспользуй соединения. Учти, что в asyncpg параметры передаются как $1, $2 вместо ?. Добавь инструмент explain_query, выполняющий EXPLAIN ANALYZE и возвращающий план запроса.