MCP-сервер на Python, uv и FastMCP

Автоматический перевод

Эта статья была автоматически переведена с оригинальной английской версии.

В этом туториале по MCP-серверу мы соберём небольшой сервер фичестора на FastMCP, запустим его через uv и подключим к Claude Desktop. Он рассчитан на Python-разработчиков, которые хотят открыть MCP-хосту один локальный рабочий процесс с базой данных. К концу у вас будет окружение с закреплённой версией SDK v1, сервер с тремя инструментами и одним ресурсом и локальная проверка, которую стоит выполнить до подключения Claude Desktop.


Что такое MCP-сервер?

Model Context Protocol (MCP) — это открытый протокол для подключения AI-приложений к внешним системам. Он заменяет отдельную кастомную интеграцию для каждого инструмента единым набором соглашений.

В этом туториале мы построим MCP-сервер FeatureStoreLite. Он стоит между LLM и фичестором, то есть базой данных с заранее посчитанными ML-фичами. Сервер предоставляет инструменты для чтения и записи векторов фичей, ключом для которых служит пользователь, продукт или документ.

Зачем это нужно?

Отладка пайплайна фичей обычно означает, что вы идёте в SQL или пишете одноразовый скрипт, чтобы проверить значение. Когда сервер запущен, вы просто спрашиваете Claude: «Какой вектор фичей у user_123?» или «Покажи метаданные для product_abc».

Зачем нужен uv?

Мы используем uv, чтобы устанавливать пакеты, разрешать зависимости и управлять виртуальным окружением. Конфигурация Claude Desktop запустит этот проект из его собственной директории с флагом --locked, поэтому он использует зависимость mcp[cli], объявленную здесь, и версии из uv.lock.

Инструменты, ресурсы и промпты

MCP-сервер может предлагать три вида сущностей. Спецификация MCP разделяет их по тому, кто решает их использовать:

ПримитивКто запускаетВ этом сервере
ИнструментМодель решает вызвать егоget_feature, store_feature, list_features
РесурсХост решает, как включить его в контекстschema://main
ПромптПользователь выбирает его, как сохранённый шаблон текстаЗдесь не используется

Инструменты позволяют модели запросить действие. Ресурсы предоставляют контекст через хост; раздел 4.4 показывает, почему доступность ресурса не гарантирует, что ответ его использует.

Обзор архитектуры

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

Архитектура FeatureStoreLite: модель Claude у провайдера модели получает ваш вопрос, список инструментов и результаты инструментов, то есть строки из features.db; на вашей машине Claude Desktop и его MCP-клиент общаются через stdin и stdout с процессом сервера FeatureStoreLite, который предоставляет три инструмента и ресурс schema://main и читает и пишет таблицу SQLite со строками user_123 и product_abcАрхитектура FeatureStoreLite: модель Claude у провайдера модели получает ваш вопрос, список инструментов и результаты инструментов, то есть строки из features.db; на вашей машине Claude Desktop и его MCP-клиент общаются через stdin и stdout с процессом сервера FeatureStoreLite, который предоставляет три инструмента и ресурс schema://main и читает и пишет таблицу SQLite со строками user_123 и product_abc

  1. Пользователь задаёт вопрос на естественном языке.
  2. Claude Desktop — это MCP-хост. Он отправляет диалог модели Claude, создаёт один MCP-клиент для этого сервера и управляет соединением.
  3. Наш сервер FastMCP предоставляет три инструмента (get_feature, store_feature, list_features) и один ресурс (schema://main).
  4. SQLite служит хранилищем для векторов фичей.

Хост передаёт список инструментов Claude. Claude может запросить tool call, но протокольные сообщения отправляет MCP-клиент для конкретного сервера. Сервер и SQLite работают локально, однако результаты инструментов попадают к модели как часть диалога. Открывайте только те данные, которые вы бы вставили в чат.


1. Настройка и установка

1.1. Установка uv

Если uv ещё не установлен, установите его. Дальше в туториале предполагается, что он есть в PATH.

# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Or via Homebrew
brew install uv

1.2. Инициализация проекта

Создайте новую директорию и инициализируйте Python-проект. Команда uv init создаёт для вас pyproject.toml.

# Create project directory
mkdir mcp-featurestore
cd mcp-featurestore

# Initialize Python project
uv init

# Add the MCP SDK with CLI tools
uv add "mcp[cli]>=1.28,<2"

Этот туториал использует API MCP Python SDK v1. Официальная документация SDK v1 предписывает пользователям v1 закреплять mcp>=1.28,<2, поэтому оставляйте верхнюю границу <2, пока не перенесёте код на новую версию. После этой команды uv.lock фиксирует всё окружение с выбранными версиями зависимостей.

Код использует FastMCP, который входит в официальный SDK (mcp.server.fastmcp). Отдельный пакет fastmcp — это другой проект с другим API, поэтому не смешивайте его документацию с этим кодом.


2. Создание сервера

Два файла, разделённые по задачам:

  1. database.py отвечает за операции с SQLite.
  2. featurestore_server.py описывает MCP-сервер.

2.1. Слой базы данных (database.py)

Этот модуль управляет соединением с SQLite и парой вспомогательных функций. Мы добавляем в него две строки с тестовыми данными, чтобы при первом запросе серверу было что вернуть.

Создайте database.py:

# database.py
import json
import os
import sqlite3

def get_db_path() -> str:
    """Get the database path - always in the script's directory"""
    script_dir = os.path.dirname(os.path.abspath(__file__))
    return os.path.join(script_dir, "features.db")

def init_db() -> None:
    """Initialize the feature store database with table and sample data"""
    conn = sqlite3.connect(get_db_path())
    conn.execute("""
        CREATE TABLE IF NOT EXISTS features (
            key TEXT PRIMARY KEY,
            vector TEXT NOT NULL,
            metadata TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)

    # Sample data for experimentation
    example_features = [
        (
            "user_123",
            "[0.1, 0.2, -0.5, 0.8, 0.3, -0.1, 0.9, -0.4]",
            json.dumps({"type": "user", "id": 123, "segment": "premium"}),
        ),
        (
            "product_abc",
            "[0.7, -0.3, 0.4, 0.1, -0.8, 0.6, 0.2, -0.5]",
            json.dumps({"type": "product", "id": "abc", "category": "electronics"}),
        ),
    ]

    # Insert if not exists
    for key, vector, metadata in example_features:
        try:
            conn.execute(
                "INSERT INTO features (key, vector, metadata) VALUES (?, ?, ?)",
                (key, vector, metadata),
            )
        except sqlite3.IntegrityError:
            pass  # Already exists

    conn.commit()
    conn.close()

def get_db_connection() -> sqlite3.Connection:
    """Get a database connection"""
    return sqlite3.connect(get_db_path())

if __name__ == "__main__":
    init_db()
    print("✅ Database initialized successfully!")

Инициализируйте базу данных:

uv run python database.py

2.2. MCP-сервер (featurestore_server.py)

Основную работу делает FastMCP. Оберните обычную Python-функцию декоратором, и она зарегистрируется как MCP-инструмент или ресурс. FastMCP строит входную схему каждого инструмента из аннотаций типов, а docstring использует как описание инструмента. Модель видит и то и другое, поэтому пишите docstring для модели: опишите, что делает инструмент и как выглядит каждый аргумент.

Создайте featurestore_server.py:

# featurestore_server.py
import json
from typing import Annotated, Any

from mcp.server.fastmcp import FastMCP
from pydantic import Field

from database import get_db_connection, init_db

# Initialize the MCP Server
mcp = FastMCP("FeatureStoreLite")

# Ensure DB is ready when server starts
init_db()

# A float that rejects NaN and infinities
FiniteFloat = Annotated[float, Field(allow_inf_nan=False)]

@mcp.resource("schema://main")
def get_schema() -> str:
    """The CREATE TABLE statements of the feature store database."""
    conn = get_db_connection()
    try:
        schema = conn.execute(
            "SELECT sql FROM sqlite_master WHERE type='table'"
        ).fetchall()
        return "\n".join(sql[0] for sql in schema if sql[0]) or "No tables found."
    finally:
        conn.close()

@mcp.tool()
def store_feature(
    key: str,
    vector: Annotated[list[FiniteFloat], Field(min_length=1)],
    metadata: dict[str, Any] | None = None,
) -> str:
    """Store a feature vector under key. Overwrites an existing key.

    vector: non-empty list of finite numbers, e.g. [0.1, 0.2].
    metadata: optional JSON object, e.g. {"type": "user"}.
    """
    metadata_json = None if metadata is None else json.dumps(metadata, allow_nan=False)
    conn = get_db_connection()
    try:
        conn.execute(
            "INSERT OR REPLACE INTO features (key, vector, metadata) VALUES (?, ?, ?)",
            (key, json.dumps(vector), metadata_json),
        )
        conn.commit()
    finally:
        conn.close()
    return f"Stored feature '{key}'"

@mcp.tool()
def get_feature(key: str) -> str:
    """Return the vector and metadata stored under key, as JSON.

    Call list_features first if you do not know the key.
    """
    conn = get_db_connection()
    try:
        row = conn.execute(
            "SELECT vector, metadata FROM features WHERE key = ?", (key,)
        ).fetchone()
    finally:
        conn.close()

    if row is None:
        raise ValueError(f"Feature '{key}' not found. Call list_features for valid keys.")
    return json.dumps(
        {
            "key": key,
            "vector": json.loads(row[0]),
            "metadata": json.loads(row[1]) if row[1] else None,
        },
        indent=2,
    )

@mcp.tool()
def list_features() -> str:
    """Return a JSON array of every stored feature key."""
    conn = get_db_connection()
    try:
        rows = conn.execute("SELECT key FROM features").fetchall()
        return json.dumps([row[0] for row in rows])
    finally:
        conn.close()

if __name__ == "__main__":
    mcp.run()

Четыре детали в этом коде определяют, что модель отправляет и что получает обратно:

  • Типизированный vector. Схема инструмента требует непустой массив чисел. В SDK 1.30 Pydantic также преобразует логические значения и числовые строки в числа с плавающей точкой: [true, false] превращается в [1.0, 0.0], а ["1.5", "2"] — в [1.5, 2.0]. Затем до запуска функции он проверяет, что список непустой, а все значения конечны. Если модель присылает вектор как JSON-строку вроде "[1, NaN]", FastMCP сначала разбирает строку. Затем allow_inf_nan=False отклоняет NaN и бесконечности, которые JSON-парсер Python принимает по умолчанию.
  • Типизированные metadata. Схема принимает JSON-объект, null или отсутствие значения. allow_nan=False также запрещает сохранять NaN в метаданных.
  • Выбрасываемые ошибки. get_feature выбрасывает ValueError, если ключа нет. Выброшенное исключение доходит до клиента как ошибка инструмента с isError: true, а возвращённая строка выглядит как успешный результат.
  • Перезапись. store_feature использует INSERT OR REPLACE, поэтому заменяет существующий ключ. Docstring говорит об этом, и модель знает об этом до вызова инструмента.

3. Тестирование в MCP Inspector

Прежде чем подключать сервер к Claude, проверьте его в MCP Inspector. Это небольшой веб-интерфейс, который подключается к серверу так же, как хост. В нём можно просмотреть ресурсы сервера, вызвать каждый инструмент со своими аргументами и увидеть каждый запрос и ответ.

SDK запускает Inspector через npx, поэтому на этом шаге также нужны Node.js и npm с npx в вашем PATH. Inspector 2.10.1 требует Node.js 22.19.0 или новее. Проверьте установленные команды, затем запустите Inspector:

node --version
npx --version
uv run mcp dev featurestore_server.py

Команда запускает сервер под MCP Inspector по stdio. Откройте URL в браузере, который выведет команда. Порт интерфейса Inspector зависит от реализации, поэтому не рассчитывайте на фиксированный порт.

Вызовите get_feature с key="user_123". Если он вернёт JSON начальной строки, сервер работает. Затем вызовите его с несуществующим ключом: результатом будет ошибка инструмента с isError: true. Так проверяются локальный путь протокола, регистрация инструментов, поиск в базе и путь ошибки. Выбор инструментов Claude это не проверяет; его проверяет раздел 4.

Inspector читает SQL-схему из ресурса schema://main:

MCP Inspector показывает ресурс get_schema и возвращённую команду CREATE TABLE для SQLite


4. Подключение к Claude Desktop

Когда Inspector подтвердил, что сервер работает, зарегистрируйте его в Claude Desktop.

4.1. Настройка Claude

Отредактируйте файл конфигурации Claude Desktop:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%/Claude/claude_desktop_config.json

Добавьте свой сервер в объект mcpServers:

{
    "mcpServers": {
        "featurestore": {
            "command": "uv",
            "args": [
                "run",
                "--directory",
                "/ABSOLUTE/PATH/TO/mcp-featurestore",
                "--locked",
                "mcp",
                "run",
                "/ABSOLUTE/PATH/TO/mcp-featurestore/featurestore_server.py"
            ]
        }
    }
}

Важно: используйте абсолютные пути и для директории проекта, и для featurestore_server.py. Claude Desktop запускает сервер как отдельный процесс. Для команды вроде mcp uv run находит проект по рабочей директории, поэтому --directory выбирает pyproject.toml и uv.lock этого проекта; --locked затем завершается ошибкой, а не меняет этот lock-файл. Команда uv add из шага 1.2 уже объявила mcp[cli] в проекте.

У SDK есть и быстрый путь: uv run mcp install featurestore_server.py сам создаст запись для Claude Desktop. В mcp 1.30 эта запись запускает uv run --frozen --with mcp[cli] mcp run /ABSOLUTE/PATH/featurestore_server.py. В ней нет --directory, поэтому она не использует uv.lock этого проекта, а у --with mcp[cli] нет границы <2. Подходит для быстрой пробы; воспроизводимой остаётся ручная конфигурация выше.

4.2. Как проходит взаимодействие

Вот что выполняется от начала до конца, когда Claude нужен поиск фичи. Шаги 1 и 2 выполняются один раз, когда Claude Desktop запускает сервер. Шаги с 3 по 6 выполняются для каждого вопроса, которому нужны данные фичей.

Диаграмма последовательности одного поиска user_123 между моделью Claude, Claude Desktop, сервером FeatureStoreLite и features.db: tools/list и описания инструментов при запуске, затем для каждого вопроса запрос get_feature от модели, сообщение tools/call, SELECT в SQLite, JSON-результат и итоговый ответ, с номерами от 1 до 6 как в списке нижеДиаграмма последовательности одного поиска user_123 между моделью Claude, Claude Desktop, сервером FeatureStoreLite и features.db: tools/list и описания инструментов при запуске, затем для каждого вопроса запрос get_feature от модели, сообщение tools/call, SELECT в SQLite, JSON-результат и итоговый ответ, с номерами от 1 до 6 как в списке ниже

  1. Claude Desktop запускает для сервера отдельный MCP-клиент и обнаруживает его инструменты.
  2. Хост делает описания инструментов доступными для Claude.
  3. Claude может запросить tool call, когда вопросу нужны данные из фичестора.
  4. MCP-клиент отправляет этот запрос серверу.
  5. Сервер выполняет Python-функцию и возвращает результат хосту.
  6. Хост может передать результат Claude для итогового ответа.

Такое разделение на хост, клиент и сервер соответствует спецификации архитектуры MCP.

4.3. Примеры запросов

Перезапустите Claude Desktop и попробуйте несколько промптов:

  1. “Покажи все доступные фичи.” Детерминированный результат содержит два начальных ключа: user_123 и product_abc.

  2. “Получи вектор фичей для user_123.” Ответ содержит вектор и метаданные premium из database.py.

  3. “Сохрани новую фичу для new_item с вектором [0.5, 0.5] и метаданными {"type": "test"}. Затем получи new_item и покажи сохранённые вектор и метаданные.” После преобразования проверенный вектор должен быть непустым и содержать только конечные числа с плавающей точкой. Метаданные могут быть JSON-объектом, null или отсутствовать. Последующее чтение проверяет весь цикл записи и чтения.

  4. “Получи вектор фичей для user_999.” get_feature выбрасывает ошибку для отсутствующего ключа. Claude должен сказать, что такого ключа нет, и может вызвать list_features, чтобы показать допустимые ключи.

4.4. Как читать ответы Claude

Сервер возвращает данные инструментов и ресурсов. Claude выбирает инструмент и пишет пояснение вокруг результата.

Этот скриншот показывает запуск в июне 2025 года с более ранней версией примера. Я попросил Claude показать схему базы данных:

Claude Desktop показывает неверную концептуальную схему FeatureStoreLite при запуске в июне 2025 года

Claude назвал систему NoSQL- или документным хранилищем и показал концептуальную схему, но database.py создаёт реляционную таблицу SQLite. Показанный ответ — не команда CREATE TABLE, которую возвращает schema://main. Скриншот не показывает, читал ли Claude Desktop этот ресурс и какой контекст получил Claude, поэтому не устанавливает причину ошибки.

Ресурсами управляет приложение: хост решает, как включить их в контекст модели — через выбор пользователя или автоматически. MCP не требует от хоста передавать модели каждый ресурс. Если модель должна запросить схему через tool call, предоставьте её также как инструмент.

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

4.5. Другие MCP-хосты

Сервер не зависит от Claude Desktop. Любой хост, который запускает stdio-серверы на той же машине, может выполнить ту же команду uv. Claude Desktop использует JSON из раздела 4.1; остальные два:

Хост на другой машине не может запустить stdio-процесс на вашей. Если сервером пользуются несколько человек, запустите его по Streamable HTTP через mcp.run(transport="streamable-http") и добавьте перед ним аутентификацию.


5. Устранение неполадок

Сначала выясните, какая часть сломалась, и только потом меняйте код: ошибки запуска видны в логах, ошибки инструмента повторяются в Inspector без модели, а неверный ответ требует сравнить результат инструмента с текстом Claude.

  • «Server connection failed»:

    • Проверьте логи в ~/Library/Logs/Claude/ на macOS или %APPDATA%\Claude\logs на Windows. mcp.log записывает события соединения. mcp-server-featurestore.log в той же папке содержит собственный stderr сервера, поэтому трейсбек при запуске оказывается там (логи Claude Desktop).
    • Убедитесь, что в конфигурации указан абсолютный путь, а не относительный.
    • Убедитесь, что uv есть в PATH Claude Desktop. Если нет, укажите полный путь к бинарнику (где он лежит, покажет which uv).
    • В stdio по stdout идут сообщения протокола. Пишите логи в stderr: print() в сервере портит этот поток и может оборвать соединение.
  • «Tool execution error»:

    • Воспроизведите ошибку в Inspector командой uv run mcp dev featurestore_server.py. Inspector показывает сообщение об ошибке и аргументы, которые её вызвали.
    • Проверьте, что features.db создаётся рядом с database.py. Путь берётся из get_db_path(), который вычисляет его относительно скрипта, поэтому смена рабочей директории не должна сдвигать файл.
  • Вызов успешен, но ответ неверный:

    • Откройте результат инструмента в чате и сравните его с текстом Claude. Раздел 4.4 показывает ответ модели, в котором база данных описана неверно.

6. Перед тем как агент начнёт использовать это всерьёз

  • store_feature перезаписывает любой ключ, включая начальные строки. Оставьте в хосте подтверждение каждого вызова для инструментов записи или вынесите их на отдельный сервер, который вы включаете только при необходимости.
  • Результаты инструментов попадают к провайдеру модели как часть диалога. Не возвращайте данные, которые должны остаться на машине.
  • Серверу, которым пользуются несколько человек, нужны Streamable HTTP и аутентификация вместо stdio (раздел 4.5).

7. Заключение

Вот и всё: сервер FastMCP, хранилище SQLite и конфигурация Claude Desktop, которая указывает на команду uv run. Та же схема подходит для большинства задач, которые можно обернуть в Python-функцию. Замените вызовы SQLite на настоящий фичестор, внутренний API или реестр моделей, и сервер останется небольшим.

Ссылки