IT · Model Context Protocol

Покращення і структурізація MCP сервера

Від моноліту main.py до структурованого qauto server

Урок 10

План заняття

  1. 01 Архітектура MCP-сервера
  2. 02 Каркас сервера
  3. 03 Що бачить LLM насправді
  4. 04 Висновки

Блок 1

Архітектура MCP-сервера

Куди що класти і навіщо кожен шар

Архітектура

З чого починаємо

Ми зібрали qauto MCP server в одному main.py: tools, prompts, resources, HTTP-клієнт, Pydantic і сесія.

Це нормальний прототип. Але коли сервер росте — один файл важко читати, тестувати і розширювати. Потрібна цільова архітектура.

Архітектура

Навіщо структурізація

Поки все в main.py, легко змішати в одному файлі:

  • Валідацію вхідних даних
  • HTTP-виклики до QAuto API
  • Логіку сесії (sid)
  • MCP-декоратори і формат відповідей

Архітектура

Що дає структура

  • Розділення відповідальностей
  • Масштабування

Архітектура

Цільове дерево проєкту

Частина 1 — корінь і пакет

mcp-example/

  • pyproject.toml
  • README.md
  • .env.example
  • data/ car_catalog.json
  • src/qauto_mcp/ основний пакет

src/qauto_mcp/

  • __init__.py
  • __main__.py python -m qauto_mcp
  • server.py FastMCP + реєстрація + lifespan
  • config.py налаштування з env
  • formatting.py єдиний формат відповідей tool

Архітектура

Цільове дерево проєкту

Частина 2 — models · services · MCP surface

models/

  • auth.py SignUpRequest, SignInRequest
  • cars.py AddCarRequest

services/

  • http_client.py створення / закриття httpx
  • session.py sid, signin_as, ensure_session
  • catalog.py довідник брендів

tools/

  • auth.py signup, signin
  • cars.py add_car, get_cars

resources/

  • cars.py qauto://cars/catalog, brands/{id}

prompts/

  • cars.py analyze_cars

Блок 2

Каркас сервера

FastMCP і опис tools для LLM

Каркас

Головна ідея

Каркас — це те, що FastMCP знімає з коду при реєстрації: імʼя сервера, instructions, lifespan і метадані tools.

Далі розберемо FastMCP(...), а потім окремо — що саме потрапляє в tools/list для LLM.

Каркас

FastMCP з параметрами

Для stdio достатньо name + instructions + lifespan.

mcp = FastMCP(
    name="qauto",
    instructions=(
        "Before add_car, read qauto://cars/catalog. "
        "For analysis use prompt analyze_cars."
    ),
    lifespan=app_lifespan,
)
  • name — імʼя сервера в Host
  • instructions — гайд на рівні всього сервера
  • lifespan — startup/shutdown (HTTP-клієнт)

Каркас

Популярні параметри

  • name — імʼя сервера в Host
  • instructions — гайд на рівні всього сервера
  • lifespan — startup/shutdown (HTTP-клієнт)
  • log_level — діагностика (на stdio — обережно)
  • warn_on_duplicate_* — попередження про дублікати

Блок 3

Що бачить LLM насправді

tools/list, декоратор і схеми

Що бачить LLM

Фасад tool

Модель не читає тіло функції, імпорти чи валідатори — лише те, що Host віддав у tools/list.

  • name / title / description
  • inputSchema — JSON Schema аргументів
  • outputSchema — якщо є структурований return type

FastMCP збирає JSON при реєстрації: декоратор → type hints → return type → docstring (якщо немає description=).

Що бачить LLM

Docstring — базовий спосіб

Якщо параметри — звичайні типи (int, str) без Field, усе пояснення кладіть у docstring.

  • Що робить tool і коли викликати
  • Args — сенс кожного поля
  • Returns — що очікувати у відповіді

Без Args/Returns модель бачить майже лише імена й типи — сенс полів губиться.

Docstring

Лише docstring

Без параметрів у декораторі: імʼя = імʼя функції, увесь docstring іде в tools/list як description.

@mcp.tool()
async def add_car(
    carBrandId: int,
    carModelId: int,
    mileage: int = 0,
) -> str:
    """Create a car for the current user (POST /cars).

    Before calling, read qauto://cars/catalog for valid ids.
    Do not use for listing cars — use get_cars.

    Args:
        carBrandId: Brand id from catalog (>= 1).
        carModelId: Model id for that brand (>= 1).
        mileage: Current mileage, 0..999999.

    Returns:
        JSON string with status_code and body (Car or error).
    """
    ...

Docstring · tools/list

Що піде в LLM

Весь docstring стає description. У inputSchema — лише типи, без описів полів.

{
  "name": "add_car",
  "description": "Create a car for the current user (POST /cars).\n\nBefore calling, read qauto://cars/catalog...\n\nArgs:\n    carBrandId: Brand id...\n    ...",
  "inputSchema": {
    "properties": {
      "carBrandId": { "type": "integer" },
      "carModelId": { "type": "integer" },
      "mileage": { "default": 0, "type": "integer" }
    },
    "required": ["carBrandId", "carModelId"]
  }
}

Що бачить LLM

Розширений опис · description=

Явний description у декораторі замінює docstring у tools/list.

@mcp.tool(
    name="add_car",
    title="Add car to garage",
    description=(
        "Add a single car to the current QAuto user garage "
        "by brand id, model id, and mileage. "
        "Before calling, read qauto://cars/catalog for valid ids."
    ),
)
async def add_car(...):
    ...

Що бачить LLM

Параметри @mcp.tool(...)

Усі аргументи опційні. Без них FastMCP візьме імʼя функції і docstring.

  • name — id tool у протоколі; без нього — імʼя функції
  • title — заголовок для UI Host
  • description — текст для LLM; замінює docstring
  • icons / meta — іконки й метадані для клієнта
  • structured_output — None / True / False
  • annotations — підказки Host про поведінку tool

Що бачить LLM · annotations

ToolAnnotations

Підказки для Host (UX / ризик), не гарантія безпеки.

  • readOnlyHint — лише читає, нічого не змінює
  • destructiveHint — зміни можуть бути руйнівними
  • idempotentHint — повтор з тими самими args без додаткового ефекту
  • openWorldHint — ходить у зовнішні системи (API тощо)

Для qauto щодня: name + title + description. Annotations — коли Host має відрізняти read-only.

Приклад 1 · без Pydantic

Plain types + description

Якщо є description= — docstring для LLM не використовується. У схемі лишаються лише типи полів.

@mcp.tool(
    name="add_car",
    title="Add car to garage",
    description=(
        "Create a car (POST /cars). "
        "Read qauto://cars/catalog first for valid ids."
    ),
)
async def add_car(
    carBrandId: int,
    carModelId: int,
    mileage: int = 0,
) -> str:
    ...

Приклад 1 · tools/list

Що піде в tools/list

{
  "name": "add_car",
  "title": "Add car to garage",
  "description": "Create a car (POST /cars). Read qauto://cars/catalog first for valid ids.",
  "inputSchema": {
    "properties": {
      "carBrandId": { "type": "integer" },
      "carModelId": { "type": "integer" },
      "mileage": { "default": 0, "type": "integer" }
    },
    "required": ["carBrandId", "carModelId"]
  }
}

У description — лише текст з декоратора. У полях схеми немає описів — для цього потрібен Field(description=...).

Приклад 2 · з Pydantic

Декоратор + моделі

Input і return type — Pydantic: у tools/list зʼявляться і inputSchema, і outputSchema.

class AddCarInput(BaseModel):
    carBrandId: int = Field(ge=1, description="Brand id from catalog (>= 1).")
    carModelId: int = Field(ge=1, description="Model id for that brand (>= 1).")
    mileage: int = Field(default=0, ge=0, le=999_999, description="Current mileage.")

class AddCarResult(BaseModel):
    status: Literal["added", "already_exists", "error"] = Field(
        description="Result of the operation"
    )
    car_id: str | None = Field(default=None, description="ID if status is added")
    message: str = Field(description="Human-readable explanation")

@mcp.tool(
    name="add_car",
    title="Add car to garage",
    description=(
        "Add a single car to the current QAuto user garage "
        "by brand id, model id, and mileage."
    ),
)
async def add_car(car: AddCarInput) -> AddCarResult:
    ...

Приклад 2 · tools/list

Що піде в tools/list

{
  "name": "add_car",
  "title": "Add car to garage",
  "description": "Add a single car to the current QAuto user garage ...",
  "inputSchema": {
    "properties": { "car": { "$ref": "#/$defs/AddCarInput" } },
    "required": ["car"]
  },
  "outputSchema": {
    "properties": {
      "status": {
        "type": "string",
        "enum": ["added", "already_exists", "error"],
        "description": "Result of the operation"
      },
      "car_id": {
        "anyOf": [{ "type": "string" }, { "type": "null" }],
        "description": "ID if status is added"
      },
      "message": {
        "type": "string",
        "description": "Human-readable explanation"
      }
    },
    "required": ["status", "message"]
  }
}

Return type → outputSchema: enum, nullable car_id, описи полів. Модель може планувати наступний крок ще до виклику.

Що бачить LLM

Коротке правило

  • LLM бачить JSON-схему, не тіло функції
  • Базовий рівень — docstring з Args/Returns + name / title
  • Розширення — явний description у декораторі

Що бачить LLM

Той самий стиль

Для resources і prompts — ті самі принципи: name, title, description.

Tools description читає переважно LLM. Resources/prompts — частіше людина в списку Host. Пишіть зрозуміло для обох.

Фінал заняття

Підсумки

На цій лекції ми розібрали цільову архітектуру qauto MCP server, каркас FastMCP і те, що бачить LLM у tools/list.

Головне для tools: docstring без декоратора vs явний description; plain types vs Pydantic з Field; annotations як підказки Host.

Далі — практичний перенос коду в структурований пакет і подальші покращення сервера.