Python≈ 35 мин с практикой

Первый MCP-сервер на Python: от пустой папки до ответа инструмента

За этот урок вы создадите локальный сервер label-lab, подключите его через stdio и получите {"label":"Draft"}. Инструмент убирает пробелы по краям подписи и принимает от 1 до 80 символов. Он не исправляет слова, не вызывает внешний API и не требует ключа модели. Готовый проект состоит из четырёх небольших файлов; проверку можно завершить в терминале, а затем открыть сервер в VS Code.

В результате

Рабочий stdio-сервер, реальный ответ Draft, три отказа и готовая конфигурация VS Code.

Перед началом

Windows, PowerShell, Python 3.11; новая пустая папка.

Скачать руководство целиком · Markdown
Граница версии

Python SDK v1 · mcp==1.30.0 · протокол 2025-11-25. Для SDK v2 требуется отдельная миграция.

Что нужно

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
PowerShellPowerShell
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 с одной строкой:

requirements-label.txtText
Скачать
mcp==1.30.0
requirements-label.txtText
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'))"
PowerShellPowerShell
.\.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 и вставьте весь код:

label_server.pyPython
Скачать
"""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")
label_server.pyPython
"""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, подтверждает инициализацию, получает список инструментов и вызывает ваш сервер. Вот полный файл:

check_stdio.pyPython
Скачать
Показать полный код · 107 строк
"""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)
check_stdio.pyPython
"""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
PowerShellPowerShell
.\.venv\Scripts\python.exe check_stdio.py

При успехе первая строка будет:

Ожидаемый вывод
PASS: initialize, tools/list, tools/call, 3 expected errors
Ожидаемый вывод
PASS: initialize, tools/list, tools/call, 3 expected errors

Ниже появится JSON. В protocol должна быть версия 2025-11-25, в tool.name — normalize_label. Для аргумента {"text":" Draft "} проверенный success выглядит так:

Ожидаемый JSONJSON
{
  "content": [
    {
      "type": "text",
      "text": "{\n  \"label\": \"Draft\"\n}"
    }
  ],
  "structuredContent": {
    "label": "Draft"
  },
  "isError": false
}
Ожидаемый JSONJSON
{
  "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:

configure_client.pyPython
Скачать
"""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)
configure_client.pyPython
"""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
PowerShellPowerShell
.\.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 (в новой вкладке), FastMCP в SDK v1 (в новой вкладке), MCP tools 2025-11-25 (в новой вкладке), stdio и Streamable HTTP (в новой вкладке), форматы конфигурации VS Code (в новой вкладке), управление серверами VS Code (в новой вкладке).

Вопросы по материалу

Почему запущенный label_server.py ничего не печатает?

Сервер в режиме stdio ждёт сообщения MCP-клиента. Для обмена запустите check_stdio.py: он сам создаёт процесс сервера и проверяет ответ. Обычный print в stdout добавлять не нужно — этот поток занят протоколом.

Нужно ли отдельно устанавливать пакет fastmcp?

Для этого урока — нет. В нём закреплён пакет mcp==1.30.0, а FastMCP импортируется из mcp.server.fastmcp. Это граница учебного примера, а не рекомендация заменить зависимости другого проекта.

Продукты ATOMNO

Другие задачи для AI-помощника

Это отдельные продукты ATOMNO для задач с данными. Они не входят в учебные примеры.

Все продукты ATOMNO (в новой вкладке)