Если вы уже знаете, что такое Model Context Protocol (MCP), и, возможно, даже читали про то, как AI-ассистент аналитика подключает LLM к Jira, Confluence и БД — вы в шаге от самого интересного. Вы стоите перед вопросом: «А как написать свой сервер? Не чужой из репозитория Anthropic, а тот, который решает мою задачу. Который подключается к моему API, моей базе, моей внутренней системе».
И вот что важно понять сразу: MCP-сервер — это не магия и не «enterprise middleware на полгода разработки». Это программа на 150–300 строк, которую вы напишете за час-два. Она слушает JSON-RPC, принимает вызовы от LLM и ходит в ваши системы. Давайте разберём, как она устроена.
Архитектура MCP-сервера: три кита — Tools, Resources, Prompts
Снаружи MCP-сервер выглядит как чёрный ящик: LLM говорит «найди баги» — сервер возвращает список. Но внутри он состоит из трёх ортогональных сущностей, и понимание этого разделения — половина успеха.
На схеме — полный путь запроса: LLM Host отправляет интент через MCP Client, тот упаковывает его в JSON-RPC, ваш сервер принимает вызов и дёргает нужный обработчик. Обработчик ходит во внешнюю систему (Jira, БД, API) и возвращает структурированный ответ. Цвета не для красоты — они показывают зоны ответственности: фиолетовый — ваш код, жёлтый — декларативное описание возможностей, зелёный — бизнес-логика, серый — внешние системы.
Три кита, на которых стоит сервер:
- Tools — функции, которые LLM может вызывать. Это активные действия: найти задачу, создать отчёт, отправить уведомление.
- Resources — данные, которые LLM может читать. Пассивный доступ: схема БД, содержимое документа, метрики дашборда.
- Prompts — шаблоны инструкций, которые сервер предлагает LLM. Не путайте с пользовательским промптом: это «пресеты» от сервера, вроде «проанализируй спринт по такой-то структуре».
Разница принципиальная. Tools = «сделай». Resources = «прочитай». Prompts = «думай вот так». Сервер может реализовать любую комбинацию из трёх — хоть все сразу, хоть только Tools.
Инструменты (Tools): что LLM может делать
Tool — это функция с именем, описанием и JSON-схемой параметров. Сервер объявляет их при старте, и LLM видит список и решает, какой вызвать.
@mcp.tool()def search_issues(project: str, status: str = "Open") -> str: """Ищет задачи в Jira по проекту и статусу. Возвращает список задач с ключами, заголовками и приоритетами.""" # бизнес-логика: запрос к Jira REST API ...Три критичных момента, которые стоит продумать при проектировании инструмента:
- Описание на естественном языке. LLM читает docstring и понимает, когда и зачем инструмент применять. Плохое описание → модель вызовет не тот инструмент или не вызовет вообще.
- Типизация параметров. JSON Schema, которую MCP SDK выводит из аннотаций типов. Если аргумент
date_from: str— LLM догадается передать строку, но лучше явно указать формат в описании ("дата в ISO формате YYYY-MM-DD"). - Идемпотентность. Инструменты на чтение (
search_*,get_*) можно вызывать повторно без последствий. Инструменты на запись (create_*,update_*) — зона риска. Я советую начинать с read-only инструментов: сначала дайте LLM возможность искать и анализировать, а право записи оставьте следующей итерации.
Ресурсы (Resources): данные, которые LLM читает
Resource — это URI-адресуемый кусок данных. В отличие от Tool, который требует вызова, Resource LLM может читать «фоном», когда ей нужен контекст.
@mcp.resource("schemas://tables")def get_table_schemas() -> str: """Возвращает описание всех таблиц БД: колонки, типы, комментарии.""" ...Когда LLM видит запрос «покажи конверсию по шагам воронки», она сначала читает схему (schemas://tables), понимает структуру данных и только потом генерирует SQL. Без Resource ей пришлось бы гадать названия колонок — или разработчик вписывал бы их в промпт вручную, убивая саму идею автоматизации.
Ресурсы особенно хороши для:
- Схем баз данных (
schemas://*) - Статических документов (
docs://спецификация-api) - Конфигураций и справочников (
config://статусы-задач) - Дашбордов и метрик (
metrics://воронка-за-неделю)
Ресурс ≠ файл. URI
schemas://tablesне обязан соответствовать реальному файлу на диске. Это логический адрес — сервер сам решает, откуда взять данные (БД, API, файловая система, память).
Промпты (Prompts): шаблоны, которые направляют LLM
Prompts — самая недооценённая возможность MCP. Это не пользовательские промпты, которыми вы общаетесь с моделью. Это шаблоны, которые сервер предлагает клиенту: «если хочешь проанализировать спринт — используй вот эту структуру рассуждений».
@mcp.prompt()def sprint_analysis(sprint_name: str) -> str: return f"""Проанализируй спринт "{sprint_name}":1. Найди все задачи спринта в Jira.2. Сгруппируй по статусу: сделано / в работе / заблокировано.3. Найди задачи без приёмочных критериев.4. Сравни velocity с предыдущим спринтом (если есть данные).5. Выдели топ-3 риска и предложи mitigation."""LLM получает этот шаблон и следует ему — это даёт воспроизводимость анализа. Сегодня один аналитик попросил разбор спринта, завтра другой — оба получат структуру одного качества.
Пишем MCP-сервер на Python: от пустого файла до первого вызова
Хватит теории. Давайте напишем сервер для Jira — минимальный, но настоящий. Python, стандартная библиотека mcp, 10 минут.
Шаг 1: Установка и скелет
pip install mcp httpxSDK mcp даёт декораторы @mcp.tool(), @mcp.resource() и @mcp.prompt(), а также транспорт. httpx — асинхронный HTTP-клиент для запросов к Jira REST API.
Минимальный скелет сервера:
from mcp.server import Serverfrom mcp.server.stdio import stdio_serverimport httpximport os
app = Server("jira-custom-server")
JIRA_URL = os.environ["JIRA_URL"]JIRA_EMAIL = os.environ["JIRA_EMAIL"]JIRA_TOKEN = os.environ["JIRA_API_TOKEN"]Три переменные окружения — и сервер знает, куда стучаться. Никаких конфигурационных файлов, никакого YAML-зоопарка. Просто и воспроизводимо.
Шаг 2: Первый инструмент — поиск задач
@app.tool()async def search_issues(jql: str, max_results: int = 10) -> str: """Ищет задачи в Jira по JQL-запросу.
Аргументы: jql: JQL-строка, например "project=PAY AND status=Open" max_results: максимум возвращаемых задач (по умолчанию 10)
Возвращает: отформатированный список задач с ключом, заголовком, статусом, исполнителем и приоритетом. """ async with httpx.AsyncClient() as client: resp = await client.get( f"{JIRA_URL}/rest/api/2/search", params={"jql": jql, "maxResults": max_results}, auth=(JIRA_EMAIL, JIRA_TOKEN), headers={"Accept": "application/json"}, ) resp.raise_for_status() data = resp.json()
lines = [] for issue in data["issues"]: key = issue["key"] fields = issue["fields"] summary = fields["summary"] status = fields["status"]["name"] assignee = fields.get("assignee", {}).get("displayName", "не назначен") priority = fields.get("priority", {}).get("name", "не указан") lines.append( f"- {key}: {summary}\n" f" Статус: {status} | Исполнитель: {assignee} | Приоритет: {priority}" )
return "\n".join(lines)Что здесь принципиально:
- Асинхронность. HTTP-запросы не блокируют сервер — пока Jira думает, сервер может обрабатывать другие вызовы. В синхронном варианте (без
async/await) сервер подвисал бы на каждом внешнем запросе. - Человекочитаемый вывод. LLM получает не сырой JSON, а отформатированный список — модель лучше понимает текст, чем вложенные структуры, и реже галлюцинирует.
raise_for_status(). Если Jira вернула 401 или 500 — сервер не молча проглатывает ошибку. Исключение всплывает до LLM, и пользователь видит «ошибка аутентификации», а не пустой список задач.
Шаг 3: Второй инструмент — детали задачи
@app.tool()async def get_issue(issue_key: str) -> str: """Возвращает подробную информацию по задаче Jira, включая описание, комментарии и связанные задачи.
Аргументы: issue_key: ключ задачи, например "PAY-341" """ async with httpx.AsyncClient() as client: resp = await client.get( f"{JIRA_URL}/rest/api/2/issue/{issue_key}", auth=(JIRA_EMAIL, JIRA_TOKEN), headers={"Accept": "application/json"}, ) resp.raise_for_status() issue = resp.json()
fields = issue["fields"] description = fields.get("description", "") description_text = ( description["content"][0]["content"][0]["text"] if description and description.get("content") else "нет описания" )
comments_resp = await client.get( f"{JIRA_URL}/rest/api/2/issue/{issue_key}/comment", auth=(JIRA_EMAIL, JIRA_TOKEN), headers={"Accept": "application/json"}, ) comments_resp.raise_for_status() comments = comments_resp.json()
lines = [ f"Задача: {issue_key} — {fields['summary']}", f"Статус: {fields['status']['name']}", f"Тип: {fields['issuetype']['name']}", f"Приоритет: {fields.get('priority', {}).get('name', 'не указан')}", f"Исполнитель: {fields.get('assignee', {}).get('displayName', 'не назначен')}", f"Описание:", f" {description_text}", f"Комментарии ({len(comments['comments'])}):", ] for c in comments["comments"][-5:]: author = c["author"]["displayName"] body = c["body"][:200] lines.append(f" [{author}]: {body}")
return "\n".join(lines)Обратите внимание: возвращается текстовое представление, а не JSON. Это осознанный выбор. Когда LLM читает JSON, она иногда путает уровни вложенности. Текст с отбивками строк она парсит увереннее.
Шаг 4: Ресурс — схема проекта
Инструменты есть, но LLM нужно знать, какие проекты и типы задач вообще существуют в Jira. Добавим ресурс:
@app.resource("jira://metadata")async def jira_metadata() -> str: """Метаданные Jira: список проектов, типы задач, статусы.""" async with httpx.AsyncClient() as client: projects_resp = await client.get( f"{JIRA_URL}/rest/api/2/project", auth=(JIRA_EMAIL, JIRA_TOKEN), headers={"Accept": "application/json"}, ) projects_resp.raise_for_status() projects = projects_resp.json()
lines = ["# Метаданные Jira\n"] for p in projects: key = p["key"] name = p["name"] lines.append(f"## Проект {key}: {name}") lines.append(f" URL: {p.get('self', '')}")
return "\n".join(lines)Теперь, когда пользователь спросит «покажи, что у нас вообще есть в Jira», LLM прочитает jira://metadata и получит карту проектов. Без ручного описания, без хардкода — сервер сам выясняет актуальное состояние.
Шаг 5: Точка входа — запуск сервера
import asyncio
async def main(): async with stdio_server() as (reader, writer): await app.run(reader, writer, app.create_initialization_options())
if __name__ == "__main__": asyncio.run(main())Пять строк. stdio_server() открывает канал стандартного ввода/вывода, app.run() запускает JSON-RPC цикл. Всё.
Полный файл jira_server.py — около 150 строк. Не тысяча, не микросервис на полгода. 150 строк, и ваша LLM умеет искать задачи в Jira и читать их содержимое.
Жизненный цикл запроса: от промпта пользователя до ответа
Давайте пройдём по цепочке событий, которые происходят, когда сервер уже запущен и пользователь задаёт вопрос.
Ключевые этапы этой последовательности:
-
Инициализация (initialize). Сервер сообщает клиенту своё имя, версию и capabilities — какие примитивы он поддерживает. Это происходит один раз при старте.
-
Объявление возможностей (tools/list). Клиент запрашивает список инструментов. Сервер возвращает массив с именами, описаниями и JSON-схемами параметров. LLM запоминает этот список и в дальнейшем выбирает инструмент под задачу.
-
Вызов инструмента (tools/call). Клиент отправляет имя инструмента и аргументы. Сервер выполняет бизнес-логику и возвращает массив
content— текстовые или бинарные блоки. -
Форматирование ответа. LLM получает структурированный ответ от сервера и переупаковывает его в человекочитаемый вид — таблицу, список, группировку.
Вся эта цепочка занимает 1–5 секунд. Основное время — HTTP-запросы к внешним системам. Сам протокол MCP добавляет доли миллисекунды.
Транспорт: stdio или SSE — что выбрать
MCP поддерживает два транспорта, и выбор влияет на архитектуру развёртывания.
| Транспорт | Как работает | Когда использовать |
|---|---|---|
| stdio | Сервер читает JSON-RPC из stdin и пишет ответы в stdout | Локальная разработка, Claude Desktop, небольшие утилиты |
| SSE (Server-Sent Events) | Сервер поднимает HTTP endpoint, клиент коннектится по HTTP + SSE | Удалённый доступ, несколько клиентов, продакшен |
stdio — простейший вариант. Сервер запускается как дочерний процесс, общение через пайпы. Claude Desktop использует именно этот транспорт. Плюс: ноль настройки сети. Минус: сервер живёт, пока живёт родительский процесс.
SSE — HTTP-сервер, к которому можно подключиться удалённо. Подходит, если сервер крутится на отдельной машине или в контейнере, а клиенты подключаются к нему из разных мест.
Для переключения на SSE достаточно заменить пять строк точки входа:
from mcp.server.sse import SseServerTransport
async def main(): transport = SseServerTransport("/mcp") async with transport.connect() as (reader, writer): await app.run(reader, writer, app.create_initialization_options())Всё остальное — инструменты, ресурсы, промпты — не меняется. Это одно из главных достоинств MCP: бизнес-логика не зависит от транспорта.
Конфигурация и запуск: подключаем сервер к Claude Desktop
Финальный шаг — зарегистрировать сервер в конфигурации хоста. Для Claude Desktop это JSON-файл claude_desktop_config.json:
{ "mcpServers": { "jira-custom": { "command": "python", "args": ["/path/to/jira_server.py"], "env": { "JIRA_URL": "https://your-company.atlassian.net", "JIRA_EMAIL": "analyst@company.com", "JIRA_API_TOKEN": "your-api-token" } } }}После перезапуска Claude Desktop (или другого хоста с поддержкой MCP) сервер появится в списке подключённых. Можно сразу задавать вопросы.
Примечательно, что переменные окружения для чувствительных данных (токены, пароли) — это сознательное решение, а не упрощение. Файл конфигурации можно хранить в системе контроля версий (без секретов), а секреты подставлять через .env или secrets manager. MCP не диктует конкретный способ — вы решаете сами.
Не только Jira: как за час адаптировать сервер под свою систему
Схема, которую мы разобрали, универсальна. Чтобы подключить другую систему, достаточно заменить три вещи:
- HTTP-клиент — вместо
JIRA_URLподставляете URL своего API. - Аутентификацию — Jira использует Basic Auth, ваша система может требовать OAuth2, API-ключ в заголовке или mTLS.
httpxподдерживает всё. - Парсинг ответа — вместо
data["issues"]— ваша структура JSON.
Сервер для PostgreSQL по той же схеме занял бы строк 80 (запросы через asyncpg вместо httpx, а в остальном — те же декораторы, те же @app.tool()).
Сервер для внутреннего API компании — строк 100. Сервер для Яндекс.Метрики — строк 120. Это не rocket science, это инженерная гигиена: один раз описал контракт — пользуешься из любой LLM с поддержкой MCP.
Ограничения: что стоит знать до продакшена
Пара моментов, которые лучше осознать на берегу:
-
Один вызов — один round-trip. Если LLM нужно получить данные из трёх систем, она сделает три последовательных вызова инструментов. Это не параллельные запросы на уровне сервера — оркестрация лежит на LLM. Для ускорения можно реализовать составной инструмент, который сам дёргает несколько эндпоинтов и возвращает агрегированный результат.
-
Токены контекста конечны. Каждый ответ инструмента попадает в контекстное окно модели. Если
search_issuesвозвращает 50 задач по 200 символов — это 10 000 токенов из доступного лимита. Решение: пагинация (max_results) и сжатие вывода (только ключевые поля, а не полный JSON). -
Обработка ошибок. Сервер должен явно обрабатывать таймауты, 5xx от внешних систем и сетевые ошибки. Если сервер падает с необработанным исключением — LLM получает сообщение об ошибке, но не может понять причину. Хороший тон: перехватывать ошибки и возвращать осмысленный текст в content.
-
Безопасность. Инструменты на запись — всегда зона риска. Начинайте с read-only. Если нужна запись — добавляйте явное подтверждение от пользователя (через отдельный инструмент или параметр
confirm=True). И помните: LLM может галлюцинировать параметры — проверяйте их на серверной стороне до выполнения действия.
Это не серебряная пуля. MCP-сервер решает проблему интеграции, но не решает проблему качества данных во внешней системе. Если в Jira бардак — сервер честно вернёт этот бардак. Но теперь хотя бы видно всё сразу.
Заключение
MCP-сервер — это не проект на месяцы и не магия. Это 150–300 строк кода, которые превращают LLM из «говорящей головы» в инструмент, подключённый к вашим реальным системам. Jira, база данных, внутреннее API компании — любой источник данных становится доступным через естественный язык.
Я бы сказал так: если вы уже пользуетесь MCP-серверами из коробки — вы на 30% пути. Остальные 70% — это кастомные серверы под ваши задачи. Те, которые решают конкретно вашу боль, а не «среднюю боль по больнице».
P.S. Полный код сервера из статьи уместится в один файл jira_server.py. Попробуйте написать свой сегодня — это быстрее, чем кажется. И гораздо полезнее, чем ещё одна интеграция через Zapier.