Logo
Overview

Как написать свой MCP-сервер: подключаем LLM к внутренним системам (Jira, БД, API)

June 26, 2026
12 min read

Если вы уже знаете, что такое Model Context Protocol (MCP), и, возможно, даже читали про то, как AI-ассистент аналитика подключает LLM к Jira, Confluence и БД — вы в шаге от самого интересного. Вы стоите перед вопросом: «А как написать свой сервер? Не чужой из репозитория Anthropic, а тот, который решает мою задачу. Который подключается к моему API, моей базе, моей внутренней системе».

И вот что важно понять сразу: MCP-сервер — это не магия и не «enterprise middleware на полгода разработки». Это программа на 150–300 строк, которую вы напишете за час-два. Она слушает JSON-RPC, принимает вызовы от LLM и ходит в ваши системы. Давайте разберём, как она устроена.

Архитектура MCP-сервера: три кита — Tools, Resources, Prompts

Снаружи MCP-сервер выглядит как чёрный ящик: LLM говорит «найди баги» — сервер возвращает список. Но внутри он состоит из трёх ортогональных сущностей, и понимание этого разделения — половина успеха.

100%
graph TB
  LLM["LLM Host<br/>Claude / GPT / Continue"]
  CLIENT["MCP Client<br/>протокол JSON-RPC"]
  SERVER["Ваш MCP Server<br/>Python / Node.js"]
  TOOLS["Tools<br/>функции доступные LLM"]
  RESOURCES["Resources<br/>данные и документы"]
  PROMPTS["Prompts<br/>шаблоны инструкций"]
  HANDLER_T["Обработчики Tools<br/>search_issues()<br/>get_sprint()"]
  HANDLER_R["Обработчики Resources<br/>schemas://tables<br/>docs://спецификации"]
  HANDLER_P["Обработчики Prompts<br/>prompt_analysis<br/>prompt_review"]
  EXT1["Jira REST API"]
  EXT2["PostgreSQL"]
  EXT3["Внутреннее API компании"]

  LLM --> CLIENT
  CLIENT --> SERVER
  SERVER --> TOOLS
  SERVER --> RESOURCES
  SERVER --> PROMPTS
  TOOLS --> HANDLER_T
  RESOURCES --> HANDLER_R
  PROMPTS --> HANDLER_P
  HANDLER_T --> EXT1
  HANDLER_R --> EXT2
  HANDLER_P --> EXT3

  style LLM fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style CLIENT fill:#e0e0e0,stroke:#999,color:#333
  style SERVER fill:#7b68ee,stroke:#5a4db2,color:#fff
  style TOOLS fill:#f0a500,stroke:#c88400,color:#fff
  style RESOURCES fill:#f0a500,stroke:#c88400,color:#fff
  style PROMPTS fill:#f0a500,stroke:#c88400,color:#fff
  style HANDLER_T fill:#50c878,stroke:#3a9a5c,color:#fff
  style HANDLER_R fill:#50c878,stroke:#3a9a5c,color:#fff
  style HANDLER_P fill:#50c878,stroke:#3a9a5c,color:#fff
  style EXT1 fill:#e0e0e0,stroke:#999,color:#333
  style EXT2 fill:#e0e0e0,stroke:#999,color:#333
  style EXT3 fill:#e0e0e0,stroke:#999,color:#333

На схеме — полный путь запроса: 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: Установка и скелет

Terminal window
pip install mcp httpx

SDK mcp даёт декораторы @mcp.tool(), @mcp.resource() и @mcp.prompt(), а также транспорт. httpx — асинхронный HTTP-клиент для запросов к Jira REST API.

Минимальный скелет сервера:

from mcp.server import Server
from mcp.server.stdio import stdio_server
import httpx
import 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 и читать их содержимое.

Жизненный цикл запроса: от промпта пользователя до ответа

Давайте пройдём по цепочке событий, которые происходят, когда сервер уже запущен и пользователь задаёт вопрос.

100%
sequenceDiagram
  participant LLM as LLM Host
  participant CLIENT as MCP Client
  participant SERVER as MCP Server<br/>ваш код
  participant SYS as Внешняя система<br/>Jira / БД / API

  Note over SERVER: Запуск сервера<br/>stdio или SSE транспорт

  CLIENT->>SERVER: initialize
  SERVER-->>CLIENT: serverInfo, capabilities

  CLIENT->>SERVER: tools/list
  SERVER-->>CLIENT: [search_issues, get_sprint, create_report]

  LLM->>CLIENT: пользователь: "найди незакрытые баги по платёжному шлюзу"
  CLIENT->>SERVER: tools/call<br/>name: "search_issues"<br/>args: {project: "PAY", status: "Open"}

  SERVER->>SYS: HTTP GET /rest/api/2/search?jql=...
  SYS-->>SERVER: JSON: 7 задач, 3 бага

  SERVER-->>CLIENT: content: [{type: "text", text: "..."}]
  CLIENT-->>LLM: структурированный ответ
  LLM-->>LLM: форматирует вывод для пользователя

Ключевые этапы этой последовательности:

  1. Инициализация (initialize). Сервер сообщает клиенту своё имя, версию и capabilities — какие примитивы он поддерживает. Это происходит один раз при старте.

  2. Объявление возможностей (tools/list). Клиент запрашивает список инструментов. Сервер возвращает массив с именами, описаниями и JSON-схемами параметров. LLM запоминает этот список и в дальнейшем выбирает инструмент под задачу.

  3. Вызов инструмента (tools/call). Клиент отправляет имя инструмента и аргументы. Сервер выполняет бизнес-логику и возвращает массив content — текстовые или бинарные блоки.

  4. Форматирование ответа. 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: как за час адаптировать сервер под свою систему

Схема, которую мы разобрали, универсальна. Чтобы подключить другую систему, достаточно заменить три вещи:

  1. HTTP-клиент — вместо JIRA_URL подставляете URL своего API.
  2. Аутентификацию — Jira использует Basic Auth, ваша система может требовать OAuth2, API-ключ в заголовке или mTLS. httpx поддерживает всё.
  3. Парсинг ответа — вместо 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.