Первый сервер и тестовый клиент
Чтение и практика ~45 мин
Ситуация
Нужен самый маленький сервер, который уже полезен: читает последние строки журнала «Заметок». И нужен способ его вызвать без модели — чтобы проверять работу, не тратя денег и не гадая, что «решила» модель.
Главная мысль
MCP-сервер — обычная программа. Хост запускает её и разговаривает через stdin и stdout. Поэтому stdout принадлежит протоколу: всё, что вы напечатаете туда сами, сломает обмен. Сообщения для человека — в stderr.
Подготовка
Нужен Python 3.12. В Ubuntu 24.04 он есть; модуль виртуальных окружений ставится пакетом python3.12-venv.
mkdir -p ~/mcp-course && cd ~/mcp-course
python3 -m venv .venv
. .venv/bin/activate
pip install mcp==2.3.0
python -c "from importlib.metadata import version; print(version('mcp'))"
Последняя строка должна напечатать 2.3.0. Версия закреплена: в SDK 2.x поменялись имена (например, прежний
FastMCP теперь MCPServer), и примеры курса проверены именно на 2.3.0.
Скачайте файлы ученика: make_lab.py, check.py и requirements.txt. Распакуйте эти три файла в ~/mcp-course, затем создайте учебную папку:
python3 make_lab.py
Скрипт создаёт ~/mcp-lab: журнал, заметки и файлы для проверок границ. Если папка уже есть, он откажет
и ничего не изменит; --fresh переименует старую и создаст новую. В учебной папке лежит метка .mcp-lab —
наш сервер будет отказываться работать без неё. Это защита от случайного запуска на настоящих данных.
Сервер
Создайте ~/mcp-course/diag_server.py:
"""Учебный MCP-сервер диагностики приложения «Заметки»."""
import os
import sys
from pathlib import Path
from mcp.server.mcpserver import MCPServer
LAB = Path(os.environ.get("MCP_LAB_DIR", "~/mcp-lab")).expanduser()
if not (LAB / ".mcp-lab").is_file():
# stdout занят протоколом, поэтому сообщения — только в stderr.
print(f"Отказ: {LAB} не учебная папка (нет метки .mcp-lab). Создайте её: python3 make_lab.py", file=sys.stderr)
sys.exit(1)
LOG = LAB / "app" / "logs" / "app.log"
NOTES = LAB / "app" / "data" / "notes"
# Записка дежурного как инструкции для агента — удобно: агент сразу знает, где журнал.
# (К этому решению курс вернётся в главе 6.)
mcp = MCPServer("notes-diag", version="0.2.0", instructions=(NOTES / "README-ops.md").read_text(encoding="utf-8"))
@mcp.tool()
def log_tail(lines: int = 20) -> str:
"""Последние строки журнала приложения «Заметки»."""
text = LOG.read_text(encoding="utf-8", errors="replace")
return "\n".join(text.splitlines()[-lines:])
if __name__ == "__main__":
mcp.run()
Что здесь происходит:
MCPServer— класс официального SDK, который берёт на себя протокол: объявление инструментов, проверку запросов, ответы. В документации SDK его импортируют короче —from mcp.server import MCPServer; это тот же класс, работают оба способа.@mcp.tool()превращает функцию в инструмент. Имя — имя функции, описание — строка документации, аргументы — параметры функции с их типами.mcp.run()по умолчанию работает через stdio.- Переменная
MCP_LAB_DIRпозволяет указать другую учебную папку;check.pyпользуется ею, чтобы проверять сервер на временных копиях.
Запустите сервер руками: python diag_server.py. Он молчит — ждёт сообщений в stdin. Остановите его Ctrl+C.
Клиент без модели
Создайте ~/mcp-course/first_client.py:
"""Первый клиент — тестовый, без модели: вызывает инструменты по списку."""
import asyncio
import sys
from mcp import Client, StdioServerParameters
async def main() -> None:
server = StdioServerParameters(command=sys.executable, args=["diag_server.py"])
async with Client(server) as client:
print("Протокол:", client.protocol_version)
print("Сервер:", client.server_info.name, client.server_info.version)
tools = await client.list_tools()
print("Инструменты:", [t.name for t in tools.tools])
result = await client.call_tool("log_tail", {"lines": 3})
print(result.content[0].text)
asyncio.run(main())
python first_client.py
Ожидаемый вывод (время в строках журнала совпадёт — журнал создаётся одинаковым):
Протокол: 2026-07-28
Сервер: notes-diag 0.2.0
Инструменты: ['log_tail']
2026-10-16T09:59:48 INFO notes.http request method=GET path=/static/app.css status=200 ua="curl/8.5.0"
2026-10-16T09:59:54 INFO notes.http request method=GET path=/notes/42 status=200 ua="curl/8.5.0"
2026-10-16T10:00:00 WARN notes.http bad bytes in query q=��
Client сам запустил сервер как процесс, спросил его server/discover, выбрал версию 2026-07-28 и дальше
отправлял её в каждом запросе. Это тестовый клиент: он не решает, какой инструмент вызвать, — вызывает тот,
что вы написали. Модель в такой схеме была бы внутри хоста и только советовала бы, какой вызов сделать.
Обратите внимание: клиент передаёт серверу не всё своё окружение, а только HOME, LOGNAME, PATH, SHELL,
TERM, USER. Ваши ключи из переменных окружения серверу по умолчанию не достаются; если серверу что-то нужно,
это передают явно (StdioServerParameters(env=...)).
Сырые сообщения
Посмотрим, что ходит по stdin и stdout. Каждое сообщение — одна строка JSON:
M='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'
( printf '%s\n' \
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"log_tail\",\"arguments\":{\"lines\":1},$M}}" \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":{\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2025-06-18\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}"
sleep 2 ) | python diag_server.py
Ответы (переносы строк добавлены для чтения):
{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"params._meta must be an object carrying the required
'io.modelcontextprotocol/protocolVersion' and 'io.modelcontextprotocol/clientCapabilities' envelope keys"}}
{"jsonrpc":"2.0","id":3,"error":{"code":-32022,"message":"Unsupported protocol version",
"data":{"supported":["2026-07-28"],"requested":"2025-06-18"}}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"2026-10-16T10:00:00 WARN notes.http bad bytes in query q=��",
"type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"result":"…"},
"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"notes-diag","version":"0.2.0"}}}}
Три урока из трёх строк:
- Запрос без версии в
_metaотклонён: сервер не помнит соединение, каждый запрос должен представиться сам. - Версия старой ревизии в
_metaотклонена ошибкой «Unsupported protocol version» со списком поддерживаемых. Старые ревизии этот SDK понимает только через прежнее рукопожатиеinitialize— в курсе его нет. - Ответы пришли не по порядку: сервер обрабатывает запросы параллельно и связывает ответ с запросом по
id.
sleep 2 держит stdin открытым: когда stdin закрывается, сервер завершается, и незаконченные запросы
пропадают. Хост поступает так же: он держит процесс сервера, пока тот нужен.
Самостоятельно
- Добавьте инструмент
log_levelsбез аргументов: сколько строк каждого уровня в журнале. Верните строку, где на каждой строкеУРОВЕНЬ ЧИСЛО, напримерERROR 13. Уровень — второе слово строки журнала. - Допишите в
first_client.pyвызовlog_levelsи печать ответа. - Напечатайте что-нибудь в stdout из инструмента (
print("debug")) и запустите клиент. Клиент SDK 2.3.0 получит строкуdebugвместо сообщения JSON, запишет в stderr ошибку разбора (Invalid JSON … input_value='debug') и продолжит. Другой хост вправе оборвать соединение, а строка, похожая на JSON, может быть принята за ответ. Верните как было и напечатайте то же в stderr (print(..., file=sys.stderr)) — ошибки разбора больше нет.
Проверка
python check.py --chapter 2 diag_server.py
check.py — тестовый клиент курса без модели. Он создаёт временную учебную папку, запускает ваш сервер
и вызывает инструменты по списку. Ожидается:
Итог: выполнено 5 из 5 проверок, пройдено 5.
Код возврата 0 — все проверки пройдены. Сообщения сервера (stderr) он сохраняет в файл, путь к которому печатает последней строкой.
Если результат другой
ModuleNotFoundError: No module named 'mcp.server.mcpserver'— установлена не та версия:pip show mcp.No module named 'mcp.server.fastmcp'в чужом примере — пример для SDK 1.x.- В выводе клиента ошибка
Invalid JSONили хост обрывает соединение — сервер печатает в stdout (шаг 3 упражнения). Отказ: … не учебная папка— запуститеpython3 make_lab.py.- В ответе
log_levelsчисла другие — проверьте, что считаете второе слово строки, и что учебная папка свежая.
Решение — answers/02-first-server.md; полный файл после главы — project/stages/02/.
Источники
- Архитектура MCP:
server/discover,_meta,tools/list,tools/call. - Пакет
mcp2.3.0:MCPServer(mcp/server/mcpserver/server.py),ClientиStdioServerParameters(mcp/client/client.py,mcp/client/stdio.py), список версий протокола (mcp_types/version.py).
Разбор и ответы — откройте после своей попытки
Инструмент log_levels (полный файл — project/stages/02/diag_server.py):
@mcp.tool()
def log_levels() -> str:
"""Сколько строк каждого уровня в журнале: по строке «УРОВЕНЬ ЧИСЛО»."""
counts: dict[str, int] = {}
for line in LOG.read_text(encoding="utf-8", errors="replace").splitlines():
parts = line.split(" ", 2)
level = parts[1] if len(parts) > 1 else "?"
counts[level] = counts.get(level, 0) + 1
return "\n".join(f"{level} {n}" for level, n in sorted(counts.items()))
Добавка в клиент (полный файл — project/stages/02/first_client.py):
result = await client.call_tool("log_levels")
print(result.content[0].text)
Ожидаемый ответ на свежей учебной папке:
DEBUG 1
ERROR 13
INFO 2361
WARN 26
Проверка: python check.py --chapter 2 diag_server.py → Итог: выполнено 5 из 5 проверок, пройдено 5.
Заметьте, что SDK уже сейчас отдаёт и structuredContent ({"result": "…"}) — строку, завёрнутую в объект.
В главе 3 результат станет настоящей структурой.
Шаг 3: после print("debug") в инструменте клиент получает в stdout строку, которая не является JSON. SDK 2.3.0
пишет в stderr ошибку разбора и продолжает работу; полагаться на это нельзя. Печать в stderr протоколу не мешает.
Полный diag_server.py после упражнения
"""Учебный MCP-сервер диагностики приложения «Заметки» — состояние после главы 2."""
import os
import sys
from pathlib import Path
from mcp.server.mcpserver import MCPServer
LAB = Path(os.environ.get("MCP_LAB_DIR", "~/mcp-lab")).expanduser()
if not (LAB / ".mcp-lab").is_file():
# stdout занят протоколом, поэтому сообщения — только в stderr.
print(f"Отказ: {LAB} не учебная папка (нет метки .mcp-lab). Создайте её: python3 make_lab.py", file=sys.stderr)
sys.exit(1)
LOG = LAB / "app" / "logs" / "app.log"
NOTES = LAB / "app" / "data" / "notes"
# Записка дежурного как инструкции для агента — удобно: агент сразу знает, где журнал.
# (К этому решению курс вернётся в главе 6.)
mcp = MCPServer("notes-diag", version="0.2.0", instructions=(NOTES / "README-ops.md").read_text(encoding="utf-8"))
@mcp.tool()
def log_tail(lines: int = 20) -> str:
"""Последние строки журнала приложения «Заметки»."""
text = LOG.read_text(encoding="utf-8", errors="replace")
return "\n".join(text.splitlines()[-lines:])
# Самостоятельный шаг главы 2.
@mcp.tool()
def log_levels() -> str:
"""Сколько строк каждого уровня в журнале: по строке «УРОВЕНЬ ЧИСЛО»."""
counts: dict[str, int] = {}
for line in LOG.read_text(encoding="utf-8", errors="replace").splitlines():
parts = line.split(" ", 2)
level = parts[1] if len(parts) > 1 else "?"
counts[level] = counts.get(level, 0) + 1
return "\n".join(f"{level} {n}" for level, n in sorted(counts.items()))
if __name__ == "__main__":
mcp.run()
Полный first_client.py после упражнения
"""Первый клиент главы 2 — тестовый, без модели: вызывает инструменты по списку."""
import asyncio
import sys
from mcp import Client, StdioServerParameters
async def main() -> None:
server = StdioServerParameters(command=sys.executable, args=["diag_server.py"])
async with Client(server) as client:
print("Протокол:", client.protocol_version)
print("Сервер:", client.server_info.name, client.server_info.version)
tools = await client.list_tools()
print("Инструменты:", [t.name for t in tools.tools])
result = await client.call_tool("log_tail", {"lines": 3})
print(result.content[0].text)
# Самостоятельный шаг: вызвать log_levels и напечатать ответ.
result = await client.call_tool("log_levels")
print(result.content[0].text)
asyncio.run(main())
Прочитали и разобрались? Отметьте урок — прогресс сохранится в этом браузере.