# Первый MCP-сервер на Python: от пустой папки до ответа инструмента

За этот урок вы создадите локальный сервер `label-lab`, подключите его через stdio и получите `{"label":"Draft"}`. Инструмент убирает пробелы по краям подписи и принимает от 1 до 80 символов. Он не исправляет слова, не вызывает внешний API и не требует ключа модели. Готовый проект состоит из четырёх небольших файлов; проверку можно завершить в терминале, а затем открыть сервер в VS Code.

## Что нужно

Windows и PowerShell, Python 3.11 с командой `py`. Для подключения в редакторе — VS Code с доступной поддержкой MCP. Встроенная проверка сервера не требует входа в ИИ-клиент. Урок закреплён за `mcp==1.30.0`, Python SDK v1, и протоколом 2025-11-25. SDK v2 — отдельная ветка: для этого примера не заменяйте версию на `latest`.

## 1. Создайте папку и окружение

В PowerShell выполните команды по одной:

```powershell
py -3.11 --version
$labPath = Join-Path $env:USERPROFILE 'mcp-label-lab'
New-Item -ItemType Directory -Path $labPath -ErrorAction Stop
Set-Location -LiteralPath $labPath
py -3.11 -m venv .venv
```

Если папка уже существует, команда остановится. Выберите новое имя для этого урока вместо перезаписи имеющегося проекта. Окружение лежит в `.venv`; активировать его не нужно — ниже в каждой команде указан его интерпретатор. Это обходит отдельную настройку выполнения PowerShell-скриптов.

Сохраните в новой папке `requirements-label.txt` с одной строкой:

```text
mcp==1.30.0
```

Установите зависимость именно в эту среду и проверьте версию:

```powershell
.\.venv\Scripts\python.exe -m pip install -r requirements-label.txt
.\.venv\Scripts\python.exe -c "from importlib.metadata import version; print(version('mcp'))"
```

Ожидаемый второй вывод — `1.30.0`. Если установка завершилась ошибкой, дальше не переходите: подключение клиента её не исправит. Отдельный пакет `fastmcp` для этого урока не нужен. Здесь `FastMCP` импортируется из установленного пакета `mcp`.

## 2. Сохраните полный сервер

Создайте рядом с requirements файл `label_server.py` и вставьте весь код:

```python
"""Local stdio tutorial for mcp==1.30.0. No external services or keys."""
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("label-lab")


@mcp.tool()
def normalize_label(text: str) -> dict[str, str]:
    """Trim a label and reject empty labels or more than 80 characters."""
    label = text.strip()
    if not 1 <= len(label) <= 80:
        raise ValueError("Expected 1 to 80 characters after trimming")
    return {"label": label}


if __name__ == "__main__":
    mcp.run(transport="stdio")
```

`@mcp.tool()` регистрирует функцию под именем `normalize_label`. Аннотация `text: str` становится строковым полем входной схемы. Дополнительное правило длины выполняется после `strip()`, поэтому строка из одних пробелов тоже отклоняется. Результат — объект с полем `label`; внутренние пробелы и регистр сохраняются.

Блок `__main__` выбирает stdio. Не добавляйте обычный `print` в сервер: stdout используется для сообщений MCP. Если запустить `label_server.py` напрямую, он будет ждать сообщения клиента. Для проверки готового обмена используйте следующий файл, который сам запустит и остановит дочерний процесс.

## 3. Получите реальный ответ без модели

Сохраните рядом `check_stdio.py`. Это небольшой клиент на стандартной библиотеке Python: он посылает initialize, подтверждает инициализацию, получает список инструментов и вызывает ваш сервер. Вот полный файл:

```python
"""Call the local tutorial via JSON-RPC/stdin, then stop its child process."""
import json
import os
from pathlib import Path
import queue
import subprocess
import sys
import threading


def main() -> dict:
    directory = Path(__file__).resolve().parent
    server = directory / "label_server.py"
    if not server.is_file():
        raise FileNotFoundError("Save label_server.py beside check_stdio.py")
    process = subprocess.Popen(
        [sys.executable, "-u", str(server)],
        cwd=directory,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
        bufsize=1,
        creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
    )
    replies = queue.Queue()
    diagnostics = []

    def read_stdout():
        for line in process.stdout:
            replies.put(line)
        replies.put(None)

    def read_stderr():
        for line in process.stderr:
            diagnostics.append(line.rstrip())

    threading.Thread(target=read_stdout, daemon=True).start()
    threading.Thread(target=read_stderr, daemon=True).start()
    transcript = []

    def send(message):
        transcript.append({"direction": "client", "message": message})
        process.stdin.write(json.dumps(message) + "\n")
        process.stdin.flush()

    def request(identifier, method, params):
        send({"jsonrpc": "2.0", "id": identifier, "method": method, "params": params})
        while True:
            line = replies.get(timeout=10)
            if line is None:
                raise RuntimeError("Server stopped: " + " | ".join(diagnostics[-4:]))
            message = json.loads(line)
            transcript.append({"direction": "server", "message": message})
            if message.get("id") == identifier:
                return message

    try:
        initialized = request(1, "initialize", {
            "protocolVersion": "2025-11-25",
            "capabilities": {},
            "clientInfo": {"name": "label-lab-check", "version": "1.0.0"},
        })
        if initialized.get("result", {}).get("protocolVersion") != "2025-11-25":
            raise AssertionError("Unexpected negotiated protocol version")
        send({"jsonrpc": "2.0", "method": "notifications/initialized"})
        listing = request(2, "tools/list", {})
        tools = listing.get("result", {}).get("tools", [])
        if [tool["name"] for tool in tools] != ["normalize_label"]:
            raise AssertionError("normalize_label is missing from tools/list")
        schema = tools[0]["inputSchema"]
        if schema["properties"]["text"]["type"] != "string" or "text" not in schema["required"]:
            raise AssertionError("Tool input schema changed")
        success = request(3, "tools/call", {"name": "normalize_label", "arguments": {"text": "  Draft  "}})
        result = success.get("result", {})
        if result.get("isError") or result.get("structuredContent") != {"label": "Draft"}:
            raise AssertionError("Unexpected normalized label")
        cases = [(4, "blank", {"text": "   "}), (5, "too_long", {"text": "x" * 81}), (6, "missing_text", {})]
        errors = {}
        for identifier, name, arguments in cases:
            reply = request(identifier, "tools/call", {"name": "normalize_label", "arguments": arguments})
            if reply.get("result", {}).get("isError") is not True:
                raise AssertionError("Expected an error for " + name)
            errors[name] = reply["result"]
        return {"passed": True, "protocol": initialized["result"]["protocolVersion"], "tool": tools[0], "success": result, "known_errors": errors, "transcript": transcript}
    finally:
        process.stdin.close()
        try:
            process.wait(timeout=3)
        except subprocess.TimeoutExpired:
            process.terminate()
            try:
                process.wait(timeout=3)
            except subprocess.TimeoutExpired:
                process.kill()
                process.wait(timeout=3)


if __name__ == "__main__":
    try:
        report = main()
        print("PASS: initialize, tools/list, tools/call, 3 expected errors")
        print(json.dumps(report, ensure_ascii=False, indent=2))
    except Exception as error:
        print("FAIL:", error, file=sys.stderr)
        raise SystemExit(1)
```

Запустите его тем же интерпретатором:

```powershell
.\.venv\Scripts\python.exe check_stdio.py
```

При успехе первая строка будет:

```text
PASS: initialize, tools/list, tools/call, 3 expected errors
```

Ниже появится JSON. В `protocol` должна быть версия `2025-11-25`, в `tool.name` — `normalize_label`. Для аргумента `{"text":"  Draft  "}` проверенный `success` выглядит так:

```json
{
  "content": [
    {
      "type": "text",
      "text": "{\n  \"label\": \"Draft\"\n}"
    }
  ],
  "structuredContent": {
    "label": "Draft"
  },
  "isError": false
}
```

Клиент не просто вызвал Python-функцию напрямую: он проверил входную схему в `tools/list` и обработал ответ `tools/call`. В конце он закрывает stdin, дожидается завершения сервера и при необходимости останавливает только созданный им дочерний процесс. После PASS не нужно оставлять терминал с сервером запущенным.

## 4. Сформируйте готовую конфигурацию VS Code

Сохраните четвёртый файл `configure_client.py`:

```python
"""Create this new lab's portable MCP configuration with absolute paths."""
import json
from pathlib import Path
import sys

directory = Path(__file__).resolve().parent
configuration = directory / ".mcp.json"
if configuration.exists():
    raise SystemExit(".mcp.json already exists; keep it and review it manually")
server = directory / "label_server.py"
if not server.is_file():
    raise SystemExit("Save label_server.py beside configure_client.py")
configuration.write_text(json.dumps({
    "mcpServers": {
        "label-lab": {
            "type": "stdio",
            "command": sys.executable,
            "args": ["-u", str(server)],
        }
    }
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("Created", configuration)
```

Выполните:

```powershell
.\.venv\Scripts\python.exe configure_client.py
Get-Content -LiteralPath .mcp.json
```

Программа создаст в корне этой новой папки `.mcp.json`, верхний ключ `mcpServers`, запись `label-lab`, `type: stdio`, абсолютный путь к Python вашей `.venv` и абсолютный путь к серверу в `args`. Имя пользователя и экранирование Windows-путей подставляются автоматически. Существующий `.mcp.json` программа не перезаписывает.

Откройте **именно папку mcp-label-lab** в VS Code. В палитре команд выполните `MCP: List Servers`, выберите `label-lab` и запустите его. Если сервер выключен, включите его; ограниченный режим доверия рабочей папки может блокировать запуск. В списке инструментов ожидайте `normalize_label` с аргументом `text`. В чате редактора включите этот инструмент через Configure Tools и попросите: «Вызови normalize_label с text равным двум пробелам, Draft и двум пробелам. Покажи значение label». Результат инструмента должен быть `Draft`. После опыта сервер можно остановить через управление MCP-серверами.

Используется переносимый workspace-формат, который текущая документация VS Code рекомендует для новых серверов. У формата `.vscode/mcp.json` другой верхний ключ — `servers`; не копируйте между ними один JSON без изменения схемы. Здесь файл уже сгенерирован в выбранном формате и не требует ручной подстановки путей.

## 5. Разберите три отказа

Проверочный клиент уже отправил три неверных вызова. В `known_errors` их ответы должны иметь `isError: true`:

| Вызов | Причина | Что исправить |
|---|---|---|
| `text: "   "` | После strip строка пустая | Передать подпись хотя бы из одного символа |
| Строка из 81 `x` | Превышен предел 80 | Сократить подпись |
| `arguments: {}` | Обязательное поле text отсутствует | Добавить строковое поле text |

У пустой и длинной строки сообщение содержит `Expected 1 to 80 characters after trimming`. У отсутствующего поля возникает ошибка входной схемы с `Field required`. Это разные уровни проверки: SDK проверяет обязательный аргумент, функция проверяет предметное правило.

Если вместо PASS выводится FAIL, сначала устраните указанную причину:

* **ModuleNotFoundError: mcp.** Проверьте версию командой из шага 1 и запускайте оба файла через `.venv\Scripts\python.exe`, а не случайный `python` из PATH.
* **Save label_server.py beside check_stdio.py.** Сохраните оба файла в одной папке, проверьте расширение `.py`, а не `.py.txt`.
* **Сервер завершился до initialize.** Строки после Server stopped покажут ошибку импорта или синтаксиса. Не меняйте транспорт, пока сервер не стартует.
* **В редакторе сервера нет.** Откройте корень папки с `.mcp.json`; проверьте запись label-lab, enabled-состояние и доверие workspace. Команда MCP: List Servers → label-lab → Show Output откроет журнал запуска.
* **Редактор показывает старую схему.** Перезапустите только label-lab после изменения файла и снова проверьте список инструментов.

## Что получилось и куда двигаться дальше

У вас есть один работающий инструмент с понятным контрактом, проверяемым успешным ответом, тремя отказами и конфигурацией клиента. Добавьте свой синтетический пример: `"  two  words  "` должен сохранить двойной пробел внутри и вернуть `"two  words"`. Затем переходите к схеме и валидации: расширяйте правило отдельно от транспорта. Сначала добейтесь нового PASS в терминале, после этого обновляйте подключение редактора.

Этот stdio-проект не открывает HTTP endpoint. Переход к удалённому серверу требует отдельного урока про транспорт, Origin и авторизацию; включение HTTP не следует из замены строки в этом примере.

## Версия, источники и проверка

Python 3.11.4, установленный SDK mcp 1.30.0, протокол 2025-11-25: сохранён реальный локальный обмен, успешный вызов и три ошибки на синтетических строках. Новая установка пакетов и интерфейс VS Code в этом прогоне отдельно не запускались; конфигурация сверена с первичной документацией, её команда и аргументы совпали с проверенным запуском. Для SDK v2 понадобится отдельная миграция.

Источники, сверены 8 октября 2026 года: [Python SDK v1](https://py.sdk.modelcontextprotocol.io/v1/), [FastMCP в SDK v1](https://py.sdk.modelcontextprotocol.io/v1/server/), [MCP tools 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools), [stdio и Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports), [форматы конфигурации VS Code](https://code.visualstudio.com/docs/agents/reference/mcp-configuration), [управление серверами VS Code](https://code.visualstudio.com/docs/agent-customization/mcp-servers).
