7 ошибок при создании MCP-серверов на FastMCP: исправляем с кодом | AiManual
AiManual Logo Ai / Manual.
13 Май 2026 Гайд

7 критических ошибок при создании MCP-серверов на FastMCP и как их исправить

Разбор фатальных ошибок в MCP-серверах на FastMCP: пустые описания, игнорирование ToolAnnotations, неверная типизация. Код, примеры, исправления.

Ошибка 1: Пустые или неинформативные описания инструментов

Посмотрим правде в глаза: большинство MCP-серверов, которые я видел за последние полгода, выглядят так, будто их писали под диктовку GPT-4 в 1:30 ночи. Самая частая проблема — отсутствие внятного description у инструмента. LLM, которая будет дёргать ваш сервер, не умеет читать ваши мысли. Она полагается исключительно на текстовое описание. Если вы напишете:

@mcp.tool()
def get_weather(city: str) -> str:
    """Получить погоду"""
    ...

Модель не поймёт, в каком формате вы отдаёте погоду, что означает возвращаемая строка, какие единицы измерения. В итоге — неверный вызов или странные ответы. Как надо: распишите, что делает функция, какие параметры, что возвращает, в каких единицах.

@mcp.tool(description="Возвращает текущую погоду в указанном городе в градусах Цельсия и влажности. Параметр city: название города на английском. Возвращает JSON с полями temp, humidity, description.")
def get_weather(city: str) -> str:
    ...

Это не «документационная» придирка. От качества описания напрямую зависит, сможет ли LLM правильно вызвать ваш инструмент. Потратьте на описание 2 минуты — сэкономите часы отладки.

Ошибка 2: Игнорирование ToolAnnotations и неявная обработка ошибок

В FastMCP начиная с версии 0.4.x (а на 2026 год актуальна 0.6) появились ToolAnnotations — метаданные, которые говорят агенту, как обрабатывать результат. Самая распространённая ошибка — не использовать их, а потом удивляться, почему модель не переспрашивает или не делает повторный вызов при ошибке.

Вот как не надо:

@mcp.tool()
def search_database(query: str) -> list:
    try:
        results = db.search(query)
        return results if results else ["Ничего не найдено"]
    except Exception as e:
        return [f"Ошибка: {e}"]

Проблема тут в следующем: модель видит список строк и не понимает, была ли ошибка на самом деле. Она может интерпретировать "Ошибка: Timeout" как валидный результат. Правильный подход — использовать ToolAnnotations и выбрасывать исключения для неуспешных случаев:

from fastmcp import ToolAnnotations

@mcp.tool(
    annotations=ToolAnnotations(
        success_indicator=lambda response: "error" not in response.lower(),
        retry_on_failure=True
    )
)
def search_database(query: str) -> list:
    if not query.strip():
        raise ValueError("Запрос не может быть пустым")
    results = db.search(query)
    if not results:
        return ["Ничего не найдено"]
    return results

Обратите внимание: мы выбросили исключение для пустого запроса — FastMCP сам передаст агенту ошибку, и он примет решение о повторной попытке или запросе уточнения. ToolAnnotations дают агенту понять, что результат успешен или нет. Это критически важно для production-среды.

Подробнее о настройке конфигураций MCP-серверов читайте в статье MCP Doctor: как автоматизировать отладку конфигов.

Ошибка 3: Неверная типизация и игнорирование встроенных валидаторов

FastMCP умеет валидировать параметры на основе type hints. Но многие пишут **kwargs или используют Any везде. Так делать нельзя — вы ломаете автодополнение для модели и теряете возможность валидации.

Пример ошибки:

@mcp.tool()
def send_email(recipient, subject, body) -> bool:
    ...

Такие голые параметры — зло. Модель не знает, что recipient — строка, а body — может быть многострочным. Исправляем:

from pydantic import EmailStr
from typing import Optional

@mcp.tool()
def send_email(
    recipient: EmailStr,
    subject: str,
    body: str,
    cc: Optional[list[EmailStr]] = None
) -> bool:
    ...

FastMCP использует pydantic для валидации, поэтому можно использовать EmailStr, Field с ограничениями. Это не только улучшает безопасность, но и даёт модели более точные подсказки.

Кстати, на практике MCP-серверы с грамотной типизацией реже попадают в список «мёртвых», который описывается в статье Кризис Model Context Protocol: 52% удаленных серверов мертвы. Не будьте частью этой статистики.

Ошибка 4: Синхронные операции в асинхронном сервере

FastMCP по умолчанию работает с asyncio. Если вы внутри async-функции вызываете time.sleep() или синхронный HTTP-запрос через requests, вы блокируете event loop. Это убивает производительность.

Как не надо:

import time, requests

@mcp.tool()
async def fetch_data(url: str) -> dict:
    time.sleep(2)  # блокирует всех!
    resp = requests.get(url)
    return resp.json()

Используем asyncio-эквиваленты:

import asyncio
import aiohttp

@mcp.tool()
async def fetch_data(url: str) -> dict:
    await asyncio.sleep(2)
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            return await resp.json()

Если же вам нужно запустить синхронный код (например, библиотеку, не поддерживающую async), оберните его в run_in_executor или используйте встроенный @mcp.tool(sync=True) (если функция синхронная, FastMCP сам запустит её в пуле потоков).

Ошибка 5: Отсутствие обработки контекста и сессий

MCP-сервер может обслуживать несколько клиентов одновременно. Если вы храните состояние в глобальных переменных, данные разных пользователей перемешаются. Типичный антипаттерн:

user_sessions = {}  # глобальный словарь

@mcp.tool()
def login(username: str, password: str) -> str:
    token = authenticate(...)
    user_sessions[id] = token  # откуда id? из космоса?
    return token

FastMCP предоставляет объект Context, который можно внедрить через аргумент ctx. Он содержит session_id и другие метаданные:

from fastmcp import Context

@mcp.tool()
def get_user_profile(ctx: Context) -> str:
    # ctx.session_id — уникальный идентификатор сессии
    # ctx.client_info — информация о клиенте
    return f"Привет, пользователь {ctx.session_id}!"

Для хранения данных сессии используйте ctx.session (если включено session management) или внешнее хранилище (Redis, база данных). Никогда не полагайтесь на глобальные переменные.

Ошибка 6: Игнорирование безопасности — открытые injection-векторы

Поскольку MCP-сервер вызывается LLM-агентом, а агент может быть скомпрометирован, ваш сервер — первая линия обороны. Самая популярная ошибка — передача пользовательского ввода напрямую в команду shell или SQL-запрос.

Пример:

import subprocess

@mcp.tool()
def run_script(script_path: str) -> str:
    result = subprocess.run(["bash", script_path], capture_output=True)
    return result.stdout.decode()

Тут script_path может быть ; rm -rf / — да, маловероятно, но модель может быть инжектирована промптом. Как защититься?

  • Ограничьте список разрешённых путей.
  • Используйте subprocess.run с shell=False (уже сделано), но обязательно проверяйте аргументы.
  • Для SQL используйте параметризованные запросы (никакой конкатенации!).
ALLOWED_PATHS = ["/home/user/scripts/"]

@mcp.tool()
def run_script(script_name: str) -> str:
    # проверяем, что script_name разрешён
    full_path = os.path.join(ALLOWED_PATHS[0], script_name)
    if not os.path.realpath(full_path).startswith(os.path.realpath(ALLOWED_PATHS[0])):
        raise PermissionError("Доступ запрещён")
    result = subprocess.run(["bash", full_path], capture_output=True)
    return result.stdout.decode()

Подробнее про угрозы prompt injection в MCP читайте в статье Как защитить AI-агентов от prompt injection. Там описан реальный кейс атаки через Claude.

Ошибка 7: Неправильная обработка потока (stream) и больших ответов

Некоторые MCP-серверы должны возвращать большие данные (например, логи, результаты поиска). Если вы возвращаете всё сразу как одну строку, агент может не уложиться в лимит контекста или просто запутаться.

FastMCP поддерживает стриминг результатов через AsyncGenerator.

Как не надо:

@mcp.tool()
def read_large_file(filename: str) -> str:
    with open(filename) as f:
        return f.read()

Как надо:

from typing import AsyncIterator

@mcp.tool()
async def read_large_file(filename: str) -> AsyncIterator[str]:
    with open(filename) as f:
        for line in f:
            yield line
            await asyncio.sleep(0)  # передаём управление

Теперь агент может обрабатывать данные порциями, не перегружая себя. Кроме того, используйте ToolAnnotations с streaming=True, чтобы модель знала, что ответ будет частями.

💡
Если вы используете FastMCP в связке с локальными LLM, обратите внимание на статью PlexMCP: универсальный шлюз для подключения локальных LLM — там показано, как организовать стриминг для моделей с ограниченным контекстом.

Бонус: как отлаживать MCP-сервер без головной боли

Классическая ситуация: вы запустили сервер, Claude Desktop или Cursor его не видит, а в логах — тишина. Раньше я тратил часы на перепроверку конфигов JSON. Теперь использую MCP Doctor — он валидирует конфиги, проверяет доступность эндпоинта и даже подсказывает, какой версии FastMCP не хватает. Если ещё не знакомы — прочитайте MCP Doctor: как автоматизировать отладку конфигов — сэкономит вам день.

А для тестирования вызовов в реальном времени отлично подходит MCP Chat Studio v2 — это Postman для MCP, с возможностью экспорта в Python.

Итог: не будьте тем разработчиком, чей сервер умирает в 52%

Ошибки, описанные выше — не теоретические. Они взяты из реальных проектов и обсуждений в сообществе. Я специально не стал писать «заключение» с перечислением каждого пункта. Вместо этого дам один совет: перед деплоем любого MCP-сервера прогоните его через валидатор ToolAnnotations и проверьте типизацию. Даже если кажется, что всё работает, — одна неправильная аннотация может похоронить совместимость с агентами.

А как вы боретесь с ошибками в своих MCP-серверах? Делитесь в комментариях — возможно, ваш опыт ляжет в основу следующей статьи.

Подписаться на канал