Типові помилки при створенні MCP-сервера

Оновлено:
Типові помилки при створенні MCP-сервера

Коли я робив MCP-сервер для свого блогу, найбільше часу пішло не на сам пошук, а на дрібниці навколо нього: клієнт не підключався через версію протоколу, локальні тести давали зовсім інші цифри, ніж продакшн, а одна необережна конфігурація могла викласти назовні приватні дані аналітики. У цій статті — всі такі моменти. Для кожного пояснюю не лише «як виправити», а й чому так відбувається, як вирішив я і які є альтернативи. Що таке MCP і як він влаштований — у основній статті про MCP-сервер для сайту.

⚡ Коротко

✅ Більшість проблем — не в коді пошуку, а навколо нього: версії протоколу, проксі, безпека фреймворку за замовчуванням, різниця між локальним середовищем і продакшном.

✅ Найнебезпечніша помилка мовчазна: фреймворк може сам опублікувати приватні інструменти — без жодного попередження.

✅ Модель — теж користувач: їй потрібні зрозумілі описи, статуси замість винятків і відповіді, які не з'їдають її контекст.

✅ Чесно позначаю: 👤 — на це я наступив сам, 🛡 — це я передбачив заздалегідь.

🎯 Ви отримаєте: список проблем, які варто перевірити до запуску, пояснення, звідки вони беруться, і чек-лист перед деплоєм.

👇 Нижче — 19 помилок у п'яти групах: підключення, безпека, ліміти, якість пошуку і «модель не так розуміє сервер».

📚 Зміст статті

📌 Розділ 1. Підключення: клієнт не бачить сервер
📌 Розділ 2. Безпека: що випадково виходить назовні
📌 Розділ 3. Ліміти і гроші
📌 Розділ 4. Якість пошуку
📌 Розділ 5. Модель не так розуміє сервер
✅ Чек-лист перед запуском MCP-сервера
❓ Часті питання (FAQ)
✅ Висновки

🔌 Розділ 1. Підключення: клієнт не бачить сервер

Найнеприємніший тип помилок: сервер працює, curl відповідає, а клієнт не підключається. Майже завжди причина — у розбіжності очікувань між клієнтом і сервером.

1.1. 👤 «Unrecognized field "form"»: клієнт і сервер на різних версіях протоколу

Симптом. MCP Inspector при підключенні показав помилку:

Unrecognized field "form" (class io.modelcontextprotocol.spec.McpSchema$ClientCapabilities$Elicitation),
not marked as ignorable (0 known properties: ])

Чому так. MCP розвивається: у специфікації кілька версій (2025-06-18, 2025-11-25 і новіші). При підключенні клієнт повідомляє серверу свої можливості, і свіжий клієнт додає туди нове поле capabilities.elicitation.form. MCP SDK на моєму сервері знав лише версію 2025-06-18 і розбирав JSON суворо: незнайоме поле — помилка, з'єднання розірвано. Це не проблема лише Inspector: така сама помилка траплялася в Claude Code при підключенні до серверів на старому Java SDK. Тобто частина реальних клієнтів просто не змогла б підключитися.

Як я виправив. Поставив перед SDK невеликий фільтр, який у запиті initialize замінює можливості клієнта на порожній об'єкт {}. Це безпечно саме для мого сервера: він лише віддає інструменти і не використовує жодну з можливостей клієнта — ні elicitation (запит даних у користувача), ні sampling, ні roots. Решта запитів проходить без змін. Заодно фільтр пише в лог, який клієнт підключився і на якій версії протоколу, — корисно для налагодження.

Альтернативи.

  • Оновити SDK або фреймворк — правильне рішення. У моєму випадку це Spring AI 2.x із підтримкою 2025-11-25, але він вимагає Spring Boot 4, тобто міграції всього сайту. Тому фільтр — тимчасовий, до міграції.
  • Старіша версія Inspector — прибирає помилку лише в Inspector. Справжні клієнти все одно не підключаться.
  • Якщо ваш сервер використовує можливості клієнта (наприклад, просить у користувача підтвердження через elicitation), вирізати їх не можна — лише оновлювати SDK.

Як не повторити. Тестуйте найсвіжішими версіями клієнтів, а не тими, що встановлені пів року тому, і логуйте initialize: версію протоколу і назву клієнта.

1.2. 👤 MCP Inspector не підключається: сервер додали як stdio замість Streamable HTTP

Симптом. Форма додавання сервера в Inspector просить Command, Arguments і Environment, а поля для URL немає.

Чому так. У MCP два принципово різні способи підключення. stdio — клієнт сам запускає сервер як локальну програму і спілкується з нею через стандартний ввід/вивід (так працюють, наприклад, сервери для доступу до файлів на вашому комп'ютері). Streamable HTTP — сервер уже працює десь у мережі, клієнт ходить до нього за URL. Я клонував приклад-шаблон, а він виявився stdio.

Як виправив. У полі Transport обрав Streamable HTTP — замість Command з'явилося поле URL.

Як не повторити. Просте правило: якщо у вашого сервера є адреса (http://…/mcp), це завжди Streamable HTTP. stdio — лише для програм, які клієнт запускає сам.

1.3. 🛡 localhost не працює в Claude і ChatGPT

Чому так. Веб-версії Claude і ChatGPT підключаються до MCP-сервера зі своїх серверів у хмарі, а не з вашого браузера. Для них localhost — це їхній власний сервер, а не ваш ноутбук.

Як роблю я. Локально перевіряю через curl і MCP Inspector, а також через клієнти, які працюють на моєму комп'ютері (Claude Code, OpenCode, Cursor) — вони бачать localhost. Веб-клієнти — лише після деплою.

Альтернатива — тунель (ngrok, Cloudflare Tunnel): він дає тимчасову публічну адресу для локального сервера. Зручно, але пам'ятайте: якщо сервер без авторизації, до нього на цей час може підключитися будь-хто, хто дізнається адресу. Для сервера з публічними даними це не страшно, для приватних — небезпечно.

1.4. 🛡 Замість JSON приходить HTML-сторінка: захист від ботів на проксі

Симптом. Локально все працює, а на продакшні клієнт не підключається; curl замість JSON повертає HTML-сторінку з перевіркою «чи ви людина».

Чому так. Якщо сайт стоїть за Cloudflare чи іншим проксі із захистом від ботів, запити від MCP-клієнтів виглядають саме як боти: це сервери, а не браузери. Проксі показує їм сторінку-перевірку, яку програма пройти не може. Друга можлива проблема — кешування: проксі може віддати збережену відповідь замість справжньої.

Як виправити. Для адреси /mcp — правило-виняток: без кешування і без перевірки на ботів. Захист від зловживань при цьому лишається на рівні застосунку (ліміти — у розділі 3).

Як не повторити. Після кожного деплою — curl на продакшн-адресу ззовні, не з самого сервера.

1.5. 🛡 403 або редирект на логін: безпека фреймворку за замовчуванням

Чому так. Фреймворки безпеки (Spring Security, Django, Laravel та інші) за замовчуванням захищають POST-запити CSRF-токеном, перенаправляють неавторизованих на сторінку входу і створюють сесію на кожен запит. MCP-клієнт CSRF-токен не надішле, логінитися не буде, а сесії від ботів лише засмічують пам'ять.

Як у мене. У моєму застосунку адмінка захищена не фреймворком безпеки, а власним перехоплювачем запитів на /admin/**. Тому я перевірив головне: що перехоплювач зареєстрований саме на /admin/**, а не на всі адреси з винятками. Інакше /mcp отримував би редирект на логін.

Якщо у вас Spring Security (або аналог) — для /mcp окремий ланцюжок правил: доступ усім, CSRF вимкнений, сесії не створюються. Додавати /mcp в основну конфігурацію сайту теж можна, але тоді ви змішуєте правила сайту й API, і сесії все одно створюватимуться.

⚠️ Чого не робити: не додавайте фреймворк безпеки в проєкт «заради MCP», якщо його там не було. Spring Security за замовчуванням закриє формою входу весь сайт, включно з блогом.

Як перевірити: curl з initialize повертає 200 без заголовка Set-Cookie, а адмінка при цьому лишається закритою.

🔐 Розділ 2. Безпека: що випадково виходить назовні

Ці помилки не дають жодних симптомів. Сервер працює, клієнти задоволені — а назовні тим часом віддається те, чого віддавати не можна.

2.1. 🛡 Фреймворк сам опублікував приватні інструменти

Чому так. MCP-стартери фреймворків зручні: позначили метод анотацією — і він уже інструмент. Але так само зручно вони збирають усі інструменти, які знайдуть у застосунку. У моєму застосунку, крім блогу, працює SEO-агент з інструментами для даних Search Console і Google Analytics. Достатньо одного рядка конфігурації, щоб ці інструменти потрапили в загальний реєстр, — і будь-хто з інтернету міг би питати в Claude про мої кліки, позиції і трафік. Без помилок і попереджень.

Як я захистився.

  • MCP-інструменти — лише через окрему анотацію і лише в окремому пакеті. Інструменти SEO-агента підключаються до свого агента напряму і ніколи не реєструються як загальні.
  • Першу перевірку tools/list я зробив ще до того, як написав хоч один інструмент. Відповідь була порожньою — значить, стартер нічого зайвого не підхопив.
  • Після кожного деплою — той самий tools/list: у списку мають бути рівно три інструменти блогу.

Альтернативи. Найнадійніше — винести MCP в окремий сервіс з доступом лише на читання до потрібних таблиць: приватних інструментів там фізично немає. Компроміс — явний білий список назв інструментів у конфігурації.

2.2. 🛡 Чернетки й видалені сторінки у векторній базі

Чому так. Векторна база живе своїм життям: стаття могла бути проіндексована як чернетка, знята з публікації або видалена, а її шматки (чанки) лишилися. Пошук по векторах про статус публікації нічого не знає.

Як роблю я. Кожен пошуковий запит з'єднує знайдені чанки з таблицею статей і пропускає лише ті, де статус «опубліковано» і дата публікації вже настала. Навіть якщо у векторній базі лишилося сміття, назовні воно не потрапить.

Альтернатива — видаляти чанки з векторної бази при знятті статті з публікації. Це правильно, але покладається на те, що індексатор ніколи не помиляється. Фільтр на рівні запиту — страховка, яка працює завжди.

2.3. 🛡 Інструменти без позначки «лише читання»

Чому так. MCP дозволяє позначити інструмент підказками: чи він лише читає (readOnlyHint), чи може щось видалити (destructiveHint). Клієнти по-різному реагують на відсутність позначок — ChatGPT, наприклад, вважає інструмент без позначки «лише читання» таким, що може змінювати дані, і поводиться з ним обережніше.

Як роблю я. Усі три інструменти позначені: readOnlyHint = true, destructiveHint = false, idempotentHint = true.

💡 Важливо: підказка — це обіцянка клієнту, а не захист. Якщо інструмент насправді щось змінює, позначка «лише читання» — це обман, і відповідальність на вас.

💸 Розділ 3. Ліміти і гроші

Відкритий сервер, де кожен виклик пошуку коштує грошей (ембединг запиту), без лімітів — це рахунок без стелі. Тому тут три лінії захисту: ліміт на IP, загальний добовий ліміт і ліміт витрат у провайдера. І одне непросте рішення — що робити, коли відмовляє сам механізм лімітів.

3.1. 🛡 Ліміт рахує IP проксі замість реального клієнта

Чому так. Коли сайт стоїть за Cloudflare, усі запити приходять на сервер з IP-адрес Cloudflare. Якщо рахувати ліміт за адресою з'єднання, «один клієнт» — це весь Cloudflare. Результат: при першому ж активному користувачі ліміт вичерпується для всіх одразу.

Як роблю я. Беру IP по ланцюжку: заголовок CF-Connecting-IP (його ставить Cloudflare) → перший IP із X-Forwarded-For → адреса з'єднання. Ліміт — 30 запитів на хвилину з одного IP; при перевищенні сервер відповідає HTTP 429 із заголовком Retry-After.

Альтернатива — ліміти на рівні самого проксі (правила rate limiting у Cloudflare). Це знімає навантаження із застосунку, але на безкоштовних планах можливості обмежені, і логіка розмазується між двома місцями. Я лишив ліміти в застосунку, де їх легко змінювати і логувати.

3.2. 🛡 Підроблений CF-Connecting-IP при зверненні напряму до сервера

Чому так. Заголовку CF-Connecting-IP можна довіряти лише тоді, коли запит справді пройшов через Cloudflare. Але у хостингу зазвичай є власна адреса сервера (у моєму випадку — технічний домен Railway), доступна напряму. Зловмисник може звернутися туди і в кожному запиті підставити новий «IP» — ліміт по IP перестає працювати.

Як у мене зараз. Чесно: прямий доступ до сервера я ще не закрив. Від витрат у цьому сценарії захищає загальний добовий ліміт (наступний пункт): він рахує всі запити разом, тому підроблені IP його не обходять. А останньою лінією лишається ліміт витрат у провайдера ембедингів.

Як правильно: приймати запити лише з IP-адрес Cloudflare, використовувати перевірку справжності запитів від проксі (authenticated origin pulls) або не публікувати технічну адресу сервера взагалі.

3.3. 🛡 Немає добового запобіжника

Чому так. Ліміт на IP захищає від одного настирливого клієнта, але не від тисячі різних: ботнет чи просто популярність сервера дадуть стільки викликів, скільки IP-адрес.

Як роблю я. Окремий лічильник на весь сервер — 5000 викликів на добу за UTC. Коли його перевищено, сервер відповідає «добовий ліміт вичерпано» і пошук не виконується, тобто ембединги не оплачуються.

А що, якщо впаде сам Redis із лічильниками?

Обидва лічильники — і по IP, і добовий — живуть у Redis. Якщо він недоступний, доводиться обирати між двома стратегіями, і обидві мають ціну:

Fail-open (пропускати)Fail-closed (відмовляти)
Що відбуваєтьсязапити обробляються без лімітівсервер відповідає «тимчасово недоступний»
Що зберігаєдоступність сервісубюджет
Ризикпоки Redis лежить, лімітів немає зовсім — ні по IP, ні добовогопроблема з кешем валить увесь MCP, навіть коли зловживань немає
Коли підходитьпублічні дані, дешеві операції, короткі збоїплатний API, дорогі AI-операції, генерація через LLM за ваш рахунок

Я обрав fail-open — і чесно про ціну: поки Redis недоступний, не працює жоден ліміт застосунку. Для мого блогу це прийнятно: один виклик — це лише ембединг запиту (частки цента), Redis падає рідко і ненадовго, а недоступний MCP заради теоретичного зловживання — гірший сценарій. Якби інструмент генерував відповіді через LLM, я б обрав fail-closed без вагань.

Що робить fail-open безпечнішим:

  • Ліміт витрат у провайдера ембедингів — обов'язкова страховка, яка не залежить ні від Redis, ні від вашого коду. Навіть якщо всі ліміти застосунку відмовлять, рахунок не вийде за встановлену суму. Найкраще — окремий API-ключ саме для MCP зі своїм лімітом: тоді зловживання через MCP не зачепить інші частини сайту, які користуються тим самим провайдером.
  • Сповіщення про бюджет — щоб дізнатися про проблему раніше, ніж про неї розповість рахунок.
  • Сповіщення про сам збій Redis — щоб «працюємо без лімітів» тривало хвилини, а не дні.
  • Лічильник у пам'яті як запасний варіант. Коли Redis недоступний, застосунок переходить на простий лічильник у власній пам'яті. Він не синхронізується між інстансами і обнуляється при перезапуску, але дає хоч якусь стелю замість жодної. Це компроміс між двома стратегіями: сервіс доступний, але й без повністю відкритого крана.
💡 Загальне правило: обирайте стратегію за ціною одного виклику. Дешевий виклик і публічні дані — fail-open плюс ліміт у провайдера. Дорогий виклик (LLM, платні API, дії з даними) — fail-closed: краще кілька хвилин недоступності, ніж непередбачений рахунок.

🔍 Розділ 4. Якість пошуку

4.1. 👤 Connection refused :11434 — локальна модель ембедингів не запущена

Симптом. Тестовий пошук локально впав з помилкою 500:

I/O error on POST request for "http://localhost:11434/api/embed": Connection refused

Чому так. Пошук спершу перетворює запит на вектор, і для цього потрібна модель ембедингів. Локально в мене це Ollama (порт 11434), на продакшні — хмарна модель. Ollama просто не була запущена. Банально, але показово: пошук — це не лише база даних, а й зовнішній сервіс, який може бути недоступним.

Як виправив. Запустив Ollama. А головне — в інструменті MCP така помилка перехоплюється і перетворюється на статус unavailable з повідомленням «пошук тимчасово недоступний», а не на незрозумілий збій для моделі (докладніше — у пункті 5.1).

⚠️ Пов'язана пастка: модель ембедингів для пошуку має бути тією самою, якою індексувалась база. Інша модель — інша розмірність векторів (у мене 768 локально і 1536 на продакшні) і помилка «different vector dimensions», або, гірше, тихо неправильні результати.

4.2. 👤 Локальні й продакшн-ембединги дають різний score — поріг не працює

Чому так. Щоб не віддавати нерелевантне, пошук відсікає результати нижче порогу схожості. Але «схожість 0.5» — не універсальна величина: кожна модель ембедингів має свою шкалу. Локальна модель видавала для тих самих запитів схожість 0.7+, а на продакшн-моделі релевантні результати — близько 0.6. Поріг, підібраний на одній моделі, на іншій або все пропускає, або все відсікає.

Як роблю я. Поріг винесений у конфігурацію: локально — 0 (дивлюся лише на логіку), на продакшні — калібрую. Калібрування просте: кілька запитів трьох типів і їхні цифри в логах. На попередньому проєкті з тією самою продакшн-моделлю вийшло так: стаття точно є — 0.60–0.63, сусідня тема — 0.43–0.49, зовсім не про сайт — 0.33–0.34. Поріг ставлю між «зовсім не про сайт» і «сусідньою темою». Значення задається змінною оточення, тому змінити його можна без нового деплою.

Як не повторити: ніколи не переносьте поріг схожості між моделями і середовищами — калібруйте там, де працює справжня модель.

4.3. 🛡 П'ять фрагментів однієї статті замість п'яти різних статей

Чому так. Довга стаття ділиться на десятки чанків, і найближчі до запиту часто належать одній статті. Наївне «топ-5 найближчих чанків» дає п'ять шматків одного тексту — модель бачить одне джерело замість п'яти.

Як роблю я. Беру 50 найближчих чанків (тут працює векторний індекс), нумерую їх усередині кожної статті, лишаю не більше двох на статтю і групую за статтею. Порядок «спершу 50 найближчих, потім групування» важливий: якщо групувати одразу, база перебиратиме всю таблицю.

Альтернатива — переранжування на різноманітність (MMR): алгоритм штрафує результати, схожі на вже обрані. Гнучкіше, але складніше; для блогу «не більше двох на статтю» дає той самий ефект простіше.

4.4. 👤 Фрагменти починаються з обрізка слова

Симптом. Фрагменти на зразок «ma на слабкому залізі…» чи «тті Топ-10 моделей…».

Чому так. При нарізці тексту на чанки сусідні шматки перекриваються, щоб не втратити контекст на межі, — і межа часто проходить посеред слова.

Як виправив. Якщо фрагмент починається з малої літери, відрізаю все до першого пробілу (але не більше 30 символів, щоб не з'їсти зайве). Кінець обрізаю по межі слова і ставлю «…».

Альтернатива — правильніша, але дорожча: різати текст на чанки по межах речень ще при індексації. Тоді обрізків не буде взагалі, але доведеться переіндексувати всю базу.

4.5. 🛡 Повна стаття у відповіді з'їдає контекст клієнта

Чому так. Стаття — це 5–10 тисяч токенів. Один такий виклик — і помітна частина контекстного вікна моделі зайнята текстом, з якого їй потрібен один абзац. А модель клієнта ще має пам'ятати розмову з користувачем.

Як роблю я. Пошук віддає фрагменти до ~900 символів, не більше двох на статтю. Окремий інструмент get_article віддає не повний текст, а «карту» статті: заголовок, короткий зміст і список розділів. Модель розуміє, що ще є в статті, а за повним текстом людина йде за посиланням.

Компроміс: повний текст зручніший для користувача, фрагменти — для контексту моделі і для кліків на сайт. Для документації, де людина рідко переходить на сайт, повний текст розділу може бути кращим вибором.

🤖 Розділ 5. Модель не так розуміє сервер

Користувач MCP-сервера — модель. Їй, як і людині, потрібні зрозумілі повідомлення.

5.1. 🛡 Винятки замість статусів: «немає даних» і «сервер зламався» виглядають однаково

Чому так. Якщо інструмент кидає виняток, клієнт отримує загальну помилку виклику. Модель не знає, що сталося: статті на цю тему немає, чи сервер тимчасово лежить? Від цього залежить, що вона скаже людині: «на цьому сайті про це не пишуть» чи «спробуйте пізніше».

Як роблю я. Кожна відповідь має поле status:

{"status": "ok",          "articles": [...]}
{"status": "not_found",   "message": "Нічого не знайдено на webscraft.org за цим запитом."}
{"status": "unavailable", "message": "Пошук тимчасово недоступний. Спробуйте пізніше."}
{"status": "error",       "message": "Query is empty."}

Усі винятки перехоплюються всередині інструмента і пишуться в лог, а модель отримує зрозумілий статус.

5.2. 🛡 Розмиті описи: модель не викликає інструмент

Чому так. Модель вирішує, чи викликати інструмент, лише за його назвою, описом і параметрами. Опис «пошук по статтях» нічого не каже про те, яких статтях і коли вони корисні. Модель просто відповість із власних знань.

Як роблю я. В описі чотири речі: про що дані (україномовний технічний блог: AI, локальні LLM, Spring Boot, RAG, SEO), коли викликати (питання «як зробити» в цих темах або прохання дати джерело), як формулювати запит (українською — так пошук точніший) і що робити з результатом (це фрагменти — завжди давати посилання).

Як перевірити: подивіться на опис очима клієнта в MCP Inspector, а потім поставте реальному клієнту питання, не згадуючи інструмент, і перевірте в логах сервера, чи був виклик. [ПЕРЕВІРИТИ НА ПРОДІ: чи викликає Claude пошук без прямої підказки]

5.3. 👤 Кирилиця в конфігурації ламає instructions

Чому так. MCP-сервер при підключенні віддає клієнту інструкцію — короткий текст про те, що це за сервер. Я задаю його в конфігураційному файлі .properties, а цей формат історично читається не в UTF-8. Кирилиця там легко перетворюється на «кракозябри» — я вже наступав на це раніше з іншими налаштуваннями.

Як роблю я. Інструкцію і описи інструментів пишу англійською: моделі однаково добре розуміють обидві мови, а проблема кодування зникає.

Альтернативи: YAML-конфігурація (читається в UTF-8), змінні оточення або текст прямо в коді.

✅ Чек-лист перед запуском MCP-сервера

  • ☐ tools/list повертає лише ті інструменти, які ви свідомо публікуєте.
  • ☐ initialize на продакшн-адресу ззовні повертає JSON, а не HTML-сторінку проксі.
  • ☐ Відповідь без Set-Cookie; адмінка й інші закриті розділи лишаються закритими.
  • ☐ Підключення перевірене найсвіжішими версіями клієнтів (Inspector, Claude Code, веб-клієнти).
  • ☐ Ліміт рахує реальний IP клієнта, а не IP проксі; при перевищенні — 429 з Retry-After.
  • ☐ Є загальний добовий ліміт на витрати.
  • ☐ Назовні потрапляють лише опубліковані дані (фільтр за статусом у самому запиті).
  • ☐ Інструменти, що лише читають, позначені readOnlyHint.
  • ☐ Поріг релевантності відкалібровано на продакшн-моделі ембедингів.
  • ☐ Фрагменти короткі, без обрізків слів, не більше 1–2 на джерело.
  • ☐ Інструменти повертають статуси (ok / not_found / unavailable), а не винятки.
  • ☐ Описи інструментів відповідають на питання «про що», «коли викликати» і «що робити з результатом».
  • ☐ У логах видно, хто підключився, який запит сформулювала модель і скільки тривав виклик.

❓ Часті питання (FAQ)

Чому клієнт не бачить інструменти мого MCP-сервера?
Найчастіше — одна з трьох причин: клієнт і сервер на різних версіях протоколу (помилка в initialize), запит перехоплює проксі чи фреймворк безпеки (HTML або редирект замість JSON), або інструменти просто не зареєструвалися. Перевірте tools/list через curl — це одразу звузить пошук.

Як дізнатися, яку версію протоколу використовує клієнт?
Клієнт передає її в запиті initialize у полі protocolVersion, разом із назвою і версією клієнта в clientInfo. Залогуйте ці поля — і ви завжди знатимете, хто і як підключається.

Чи обов'язковий Redis для лімітів?
Ні. Для одного інстансу сервера достатньо лічильника в пам'яті. Redis потрібен, коли інстансів кілька або ліміти мають переживати перезапуск.

Як перевірити, що назовні не виходить нічого зайвого?
Викликайте tools/list після кожного деплою і порівнюйте зі списком, який ви свідомо публікуєте. Найкраще — автоматично, у перевірці після деплою.

Чи можна перевірити сервер у ChatGPT чи Claude без деплою?
Лише через тунель (ngrok, Cloudflare Tunnel), який дає локальному серверу тимчасову публічну адресу. Для локальних тестів простіше Inspector і клієнти на вашому комп'ютері — Claude Code, Cursor, OpenCode.

✅ Висновки

Сам пошук у MCP-сервері — найпростіша частина. Більшість помилок живе на межах: між версіями протоколу, між сервером і проксі, між локальним середовищем і продакшном, між вашим кодом і «зручною» автоматикою фреймворку. Тому головне правило — перевіряти не код, а те, що бачить клієнт: initialize, tools/list і реальні виклики ззовні, після кожного деплою.

Як влаштований MCP і які архітектурні рішення варто прийняти до того, як писати код, — у основній статті про MCP-сервер для сайту. А якщо потрібен MCP для вашого сайту чи документів компанії — напишіть нам.