LIMB
Вход Регистрация

тема

Ищу открытые MCP-серверы в официальном реестре

CuriousGuy · · 4 ответов · 351 просмотров

Интро

MCP (Model Context Protocol) за последний год прошёл путь от «ещё один стандарт от Anthropic» до де-факто способа подключать агента к чему угодно — базам данных, хостингу, платёжным системам, медицинским записям пациентов. У протокола есть официальный публичный реестр (registry.modelcontextprotocol.io), куда операторы сами добавляют свои серверы, чтобы их находили. Возникает простой вопрос: раз это не чат-бот с текстовым полем (см threads/7931/), а сервер с реальными исполняемыми инструментами — create_record, execute_query, delete_account — то что происходит с авторизацией на этой волне массового разворачивания.

Написал mcp-exposure-auditor — read-only аудитор, который делает initialize + tools/list/resources/list/prompts/list и по результату классифицирует, что сервер отдаёт без единого креда. Прогнал на 3903 реальных серверах из официального реестра. Дальше — что из этого вышло, какие баги поймал по пути, и почему сырая цифра «CRITICAL» на выходе оказалась намного менее интересной, чем то, что осталось после того, как её честно перепроверил.

Почему для этого нет готового тула

Три причины, и структура та же, что и с прошлой темой (chat-виджеты): форма существующих инструментов не совпадает с формой задачи.

Первая: HTTP-сканеры (Burp, ZAP, nuclei) видят запрос-ответ-статус-код, но у MCP вся семантика — внутри JSON-RPC тела. tools/list, отданный без авторизации, для обычного сканера выглядит как честный 200 OK с валидным JSON — снаружи неотличимо от любого другого API-ответа, если не знать протокол.

Вторая: LLM red-teaming тулы (Garak, PyRIT) бьют в сам инференс — промпт-инъекции, джейлбрейки. MCP-сервер эту задачу не решает и не должна решать — вопрос тут не «что скажет модель», а «что вернёт транспорт до того, как модель вообще подключится».

Третья: единого формата MCP-сервера тоже нет. Streamable HTTP, устаревший SSE, авторизация где-то в заголовке, где-то в параметре инструмента, где-то отдельным set_api_key-тулом внутри сессии, где-то формальной OAuth2-декларацией по спеке. Написать классификатор, который не путает эти случаи между собой — это и есть основная инженерная работа, а не разовый скрипт «постучаться и посмотреть на код ответа».

Собираю аудитор

Ядро — не официальный MCP SDK-клиент, а свой, сознательно:

Python:
PROBE_TOOL_NAME = "__mcp_exposure_auditor_nonexistent_probe_tool__"

Задача клиента — пережить контакт со сломанным или откровенно враждебным сервером, а не красиво поговорить с хорошим. Разница принципиальная: официальный SDK-клиент рассчитан на добропорядочный сервер по ту сторону, а здесь по ту сторону — случайный сервер из реестра, который может ответить чем угодно.

Три живых бага, которые поймал стенд до того, как это ушло на реальные цели

Прежде чем бить по чужой инфраструктуре, собрал четыре локальных тестовых сервера (открытый, за API-ключом, за OAuth, и «частично защищённый» — tools/list открыт, tools/call закрыт) и прогнал 52 теста. Три бага стенд поймал раньше, чем это стало проблемой на живых целях:

Зависание на partial-auth варианте. Первая версия ASGI-мидлвари, которая гейтит только tools/call, буферизовала тело запроса и подсовывала обёрнутому приложению синтетический receive(), который вечно возвращал одно и то же пустое сообщение вместо того, чтобы после однократной отдачи буфера передать управление реальному receive(). ASGI-контракт этого не прощает: если хендлер после чтения тела легитимно блокируется на receive() в ожидании сигнала дисконнекта, а колбэк никогда не блокируется и не сигналит дисконнект — запрос висит вечно. Каждый запрос к этому варианту стенда зависал до таймаута клиента.

Крах, а не медленный ответ, на большом tools/list. У asyncio.StreamReader.readline() дефолтный лимит буфера 64 КиБ. Ответ на tools/list с ~20000 инструментами — это несколько мегабайт одной строкой, readline() в этом случае не отдаёт длинную строку, а роняет asyncio.LimitOverrunError. Реальный сервер с раздутым списком инструментов — специально или просто по факту большого продукта, как выяснилось позже на реальных целях с 600+ инструментами — уронил бы сканер исключением вместо классифицированной находки.

Таймаут работал только для настоящей сети. httpx.AsyncClient(timeout=...) честно работает на реальных сокетах, но httpx.ASGITransport, который используется для быстрых in-process тестов, таймауты не соблюдает вообще. Вместо того чтобы просто подстроиться под тестовое окружение, обернул каждый HTTP-запрос в явный asyncio.wait_for — таймаут теперь принудительный со стороны аудитора независимо от транспорта под капотом.

Все три — до контакта с единственным реальным сервером.

Discovery: тупик с Shodan, поворот к официальному реестру

Первая идея была очевидной — Shodan. Ключ есть, но:

Code:
GET /shodan/host/search?key=...&query=jsonrpc
{"error": "Requires membership or higher to access"}

Free-тир отдаёт только host/count (сухую цифру совпадений в индексе), не сами IP — для реального списка нужен платный Membership. Плюс MCP-эндпоинты обычно висят на нестандартных путях (/mcp, /sse) поверх обычного сайта, и без отдельного модуля именно под MCP-протокол Shodan их банально может не видеть при обычном краулинге корня. Коротко — скип.:)

Развернулся к официальному реестру — там операторы сами публикуют remote-эндпоинты, потому что хотят, чтобы их находили:

Python:
def fetch(cursor=None, limit=100):
    url = f"{BASE}?limit={limit}"
    if cursor:
        url += f"&cursor={cursor}"
    return json.loads(subprocess.run(["curl", "-s", url], capture_output=True, text=True).stdout)

Курсор постранично отдаёт servers[] + metadata.nextCursor. За ~135 страниц по 100 записей набралось 13500 сырых записей реестра; после фильтра isLatest=true + status=active + дедупликации по хосту — 3903 уникальных remote-эндпоинта. Курсор идёт по name:version, и часть записей — это версии одного и того же сервера (один нашедшийся кейс отдал ~600 версий подряд одного пакета) — сырых записей поэтому в разы больше, чем реальных уникальных серверов.

Первый прогон: сырая цифра, которая обманывает

Аудитор гоняет цели параллельно (asyncio.Semaphore(15)) — на каждый конкретный сервер всё равно уходит ровно тот же набор из ~5 read-only запросов, что и при последовательном прогоне, параллелизм только между разными хостами:

Code:
278 просканировано: 113 CRITICAL (40.6%) · 137 PROTECTED · 26 INDETERMINATE · 2 MEDIUM

Первая реакция — «почти половина всего реестра открыта нараспашку». Неправда, и вот почему.

Живая находка: собственный классификатор был неточным

CRITICAL в модели тулза означает буквально «tools/list ответил без авторизации» — факт, который сам по себе всегда верен. Проблема в том, что этот факт — плохой прокси для «это баг». Два реальных паттерна путают статистику:

Первый — формальная декларация авторизации прямо в объекте инструмента, часть спеки MCP:

JSON:
{
  "name": "list_projects",
  "securitySchemes": [{"type": "oauth2", "scopes": ["mcp"]}]
}

Сервер, у которого каждый отданный инструмент несёт такую декларацию, скорее всего спека-комплаентно реализует «discovery открыт, инвокация — по OAuth2», а не забыл авторизацию вообще. Один из найденных кейсов с CRUD-инструментами на клиентские записи изначально прочитался как «нулевой auth» — до тех пор, пока не полез за полной схемой каждого инструмента и не увидел эту декларацию на всех 120 из 120.

Второй паттерн — pay-per-call маркетплейсы на протоколе x402: 300+ инструментов, у каждого в описании прямым текстом «PAID MCP TOOL — $0.05 USDC per successful call via native x402. Discovery is free». Открытое перечисление здесь не баг, а буквально написанная фича.

Добавил оба сигнала в классификатор:

Python:
def _declared_auth_ratio(tools):
    if not tools:
        return None
    with_scheme = sum(1 for t in tools if isinstance(t, dict) and t.get("securitySchemes"))
    return with_scheme / len(tools)
def _payment_gated_hint(probe):
    text = " ".join(...).lower()
    if "discovery is free" in text:
        return True
    if "x402" in text and any(h in text for h in ("per call", "per successful call", "usdc", "usdt")):
        return True
    return False

Важный дизайн-выбор: likely_false_positive не понижает и не подменяет Severity.CRITICAL — факт «отдал без авторизации» никуда не девается, он просто дополняется доказательной базой для человека (или для следующего слоя автоматизации) поверх голого севирити, в том же духе, что уже был у tools_call_gated=None («не проверено», а не угадано). Плюс — если хотя бы часть инструментов реально не гейтится, пробный вызов на несуществующее имя тула больше не подписывает автоматом «инвокация тоже возможна», если сам сервер явно требует OAuth2 на большинстве инструментов — старая формулировка это как раз путала.

Дописал 5 тестов на новую логику, прогнал все 57 — зелёные. Пересканировал весь набор из 1806 ранее-CRITICAL целей уже через исправленный классификатор:

Code:
CRITICAL: 1801, likely_false_positive: 170 (141 payment-gated, 30 declared securitySchemes)

170 из 1801 (~9.4%) теперь отфильтровываются автоматически, без единого ручного прочтения. Это не всё — детектор ловит только два структурных сигнала, а не «просто бесплатно по политике оператора без формальной декларации» (например, публичные гос-данные без единого упоминания auth вообще — таких в выборке ещё десятки, и их отфильтровывает только чтение текста руками). Но эти 9.4% — реальное, воспроизводимое, тестами покрытое улучшение точности, а не разовая ручная зачистка одной выборки.

Что даёт каждый конкретный тип находки

Не всякий leak одинаково значим — тип утечки определяет, что вообще можно (и нужно) сделать дальше.

Полная карта административного API реального продукта (сотни инструментов, часть из них — деструктивные: удаление, прямой SQL, управление ключами). Здесь важно разделять две разные вещи, которые легко перепутать. Первая — сам факт, что любой, кто нашёл URL, видит полную схему всех возможностей продукта без единого запроса авторизации: имена методов, обязательные параметры, внутреннюю архитектуру. Это уже находка сама по себе — разведка боем, которая обычно стоит отдельного пентеста. Вторая, отдельная вещь — работает ли реальная запись без авторизации. В части найденных случаев в описании самих инструментов прямым текстом «Requires: API key with write scope», и есть отдельный setup_api_key-подобный тул для сессии — то есть авторизация, скорее всего, реализована, просто не на уровне протокола, а внутри хендлера. Ровно этот паттерн — у AdButler (622 инструмента, CRUD + ключи подписи, setup_api_key) и у BorealHost (144 инструмента, включая execute_query/add_ssh_key/delete_account, set_api_key) — детали обоих ниже, в разделе с именами.

Полное отсутствие какого-либо auth-сигнала во всём объёме инструментов. Самый чистый по диагностике случай: ни формальной декларации, ни параметра ключа в схеме, ни отдельного tool для настройки сессии — вообще ничего, на всех инструментах разом. Здесь и рутинная проверка tools_call_gated (пробный вызов на несуществующее имя) не встречает ни одной преграды на уровне роутинга. Технически это самый однозначный «забыли авторизацию» из всех найденных — но именно этот тип чаще всего оказывается ловушкой при ручной перепроверке. У одного из кандидатов на эту категорию (0 сигналов авторизации на всех 18 инструментах) при более внимательном разборе выяснилось: сам сервер — намеренно бесплатный, публично анонсированный как «points to the live endpoint for discovery on MCP directories» discovery-слой, лидген для основного (платного, тяжело KYC-защищённого) продукта той же компании, а не забытая защита. «Никто ничего не гейтит» — не то же самое, что «нашли баг», если оператор именно так и задумывал; из-за этого в финальный список ниже такой пример не попал.

Частичный гейт — часть инструментов явно требует ключ, самые чувствительные — нет. Отдельная, более неприятная категория: у административных/пишущих операций в описании явно указано требование ключа (заголовок или параметр), а у read-запросов, которые как раз и возвращают самое чувствительное — персональные записи, финансовую историю, служебные заметки о клиентах, — ни в схеме, ни в описании ни одного упоминания авторизации нет вообще. Ровно тот паттерн, ради которого исходно и строился этот класс инструмента: судя по формальной схеме tools/list, команда закрыла запись, но не подумала, что чтение того же самого объекта данных остаётся нараспашку. Загвоздка в том, что схема — не рантайм: у одного из кандидатов на эту категорию, где нашёлся доступ к настоящему (собственному, пустому) аккаунту, чтобы это перепроверить, вызов без ключа на практике всё же честно отклонялся с внятной ошибкой, несмотря на то что в схеме apiKey формально не был обязательным. Без такого доступа к реальному аккаунту снаружи это от «настоящей дыры» не отличить — именно поэтому этот тип находки самый неудобный для проверки удалённо и самый ненадёжный без неё.

Формально задекларированная авторизация (securitySchemes) на почти всех отданных инструментах. Это тот самый ложноположительный случай — сервер спека-комплаентно разделяет «список открыт» и «вызов защищён». Достойно упоминания в статистике честности инструмента, но не в списке находок.

Явный pay-per-call маркетплейс. Тоже не находка — открытое перечисление тут прямо продекларированная фича протокола монетизации, а не оплошность оператора.

Честная граница: что не проверялось и почему

Дальше — то, ради чего вообще стоит разделять «подтверждено» и «правдоподобно, но не проверено», а не валить всё в одну кучу «нашли N уязвимостей».

Подтверждено во всех случаях: неавторизованный initialize и хотя бы один list-метод (tools/list/resources/list/prompts/list) реально отвечают без единого заголовка авторизации, содержимое ответа — реальный, воспроизводимый JSON-RPC с конкретного эндпоинта в конкретный момент времени.

Но я не стал копать дальше. То что называют самым соком — часть найденных серверов управляет реальными деньгами (платёжные/эквайринговые интеграции), реальной инфраструктурой (SSH, прямой SQL к боевой базе), я не особо в этом силен, боюсь накосячить. Поэтому копнул как сам смог, не ругайте если покажется поверхностно.


Два конкретных случая

1. BorealHost — https://borealhost.ai/mcp/.


Не удалось прогрузить медиа
​

Реальный хостинг-провайдер. 144 инструмента видно без авторизации. Есть set_api_key, реальная запись не подтверждена — но сама видимость этого набора уже разведка боем на чужую инфраструктуру. Часть отдаваемых имён инструментов:

Code:
execute_query   · add_ssh_key   · read_file   · write_file
delete_account  · rollback_snapshot   · get_billing_portal
set_api_key

Постскриптум к BorealHost: что скрывается за «запись не подтверждена» — команда за командой

Ниже —  фактические команды и ответы, часть — из первого прохода (тогда была получена боевая Stripe-ссылка), часть передоказана свежим прогоном прямо при подготовке этой версии статьи (отдельно помечено).

Первым делом — initialize без единого заголовка Authorization. Он не просто открыт, он отдаёт развёрнутый гайд по собственной модели авторизации:

Code:
curl -sS -N -X POST 'https://borealhost.ai/mcp/' \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  --data-raw '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}'

Code:
"instructions": "...
- New agents: Call register() — no auth needed, returns a fresh read+write key and activates it
- Some tools require no authentication: list_plans, create_checkout, update_checkout,
  complete_checkout, get_checkout_status, request_api_key
..."

Вывод: это не догадка по пустой JSON-схеме — оператор сам, прямым текстом, документирует список публичных тулов. Дальше: проверить, что register() реально выдаёт «read+write», как обещано.

Code:
curl -sS -N -X POST 'https://borealhost.ai/mcp/' ... \
  --data-raw '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"register","arguments":{"name":"Article Verification Probe"}}}'

JSON:
{
  "api_key": "bh_7936db84...", "prefix": "bh_7936db84",
  "scopes": ["read"],
  "account_id": "dbcb4324-7449-4489-b664-7a52132fb58b",
  "message": "Store this API key securely — it cannot be retrieved again."
}

Вывод: докстринг обещал read+write, реально выдан только read — расхождение документации и рантайма, воспроизведённое сейчас на совершенно новом ключе (свежий прогон при подготовке статьи; в первом заходе на другом ключе — bh_742d4fce... — было то же самое, так что это не случайность одного прогона).

Дальше: раз ключ реальный — посмотреть, что он видит.

Code:
--data-raw '{"...","name":"whoami","arguments":{}}'

JSON:
{"user":{"email":"[email protected]", ...},
 "api_key":{"scopes":["read"], ...},
 "account":{"sites": 0, "active_subscriptions": 0}}

Вывод: аккаунт настоящий, но пустой. Дальше: проверить, работает ли read-scope на данные ЧУЖОГО сайта, или сервер честно режет по ресурсу.

Code:
--data-raw '{"...","name":"list_databases","arguments":{"slug":"some-other-persons-site-9182"}}'

Code:
Error executing tool list_databases: NOT_FOUND: Site not found

Вывод: отказ честный — ресурсный слой сработал, пустой read-ключ не открывает чужие сайты. Дальше: раз запись через create_checkout тоже в списке «no auth needed» — довести её до реального объекта, не только до докстринга.

Code:
--data-raw '{"...","name":"create_checkout","arguments":{"sku":"bh_site_starter_monthly"}}'

JSON:
{"id": "5a5ef664-634f-48fe-bb3c-e89592522b32", "status": "not_ready",
 "checkout_secret": "083Y5m0Hve3ikIi..."}

Вывод: анонимный вызов без единого креда создал реальный, живой объект checkout в БД оператора (id и секрет настоящие, не заглушка). В первом заходе этот же флоу довели до конца через update_checkout → complete_checkout и получили боевую Stripe-ссылку — checkout.stripe.com/g/pay/cs_live_... (cs_live_, не cs_test_: это прод-биллинг, платёж списал бы реальные деньги, если бы прошёл до оплаты, чего мы не делали).

Дальше, уже отдельно от гонки за самим фактом обхода — картирование того, что вообще видно этому read-ключу:

Code:
--data-raw '{"...","name":"list_plans","arguments":{}}'

JSON:
{"slug":"site_starter","price":{"monthly":5,"annual":2,"currency":"CAD"},...}
{"slug":"vps_6","price":{"monthly":193,"annual":163,"currency":"CAD"},...}

(18 планов, весь прайс-лист от 5 до 193 CAD/мес)

Вывод: полная сетка тарифов и лимитов конкурента вытаскивается одним анонимным вызовом — не баг (тул специально публичный), но business-чувствительно.

Code:
--data-raw '{"...","name":"list_api_keys","arguments":{}}'

JSON:
{"id":"20c782fc-...", "scopes":["read"], "site_scoped": false, "disabled_tools": [], ...}

Вывод: поля site_scoped/disabled_tools показывают, что у оператора есть инфраструктура для тонкого скоупинга ключей (привязка к сайту, отключение отдельных тулов) — просто дефолтный register()-ключ её не использует. list_apps/get_metrics без slug падают на валидации ещё до всякой auth-проверки — тот же паттерн resource-scoping, что и у list_databases.

2. AdButler — https://mcp.adbutler.com/mcp.

Не удалось прогрузить медиа

​
Узнаваемая рекламная платформа. 622 инструмента — полный CRUD (advertisers, campaigns, SFTP-подключения, ключи подписи рекламных beacon'ов) виден без авторизации. Есть setup_api_key — сессионный ключевой механизм, то есть запись, вероятно, всё же на чём-то держится; это не подтверждённый обход, только видимость полной карты возможностей продукта без единого креда. Часть отдаваемых имён инструментов:

Code:
create_advertiser · update_advertiser · delete_advertiser
create_campaign   · update_campaign   · delete_campaign
create_beacon_signing_key · delete_beacon_signing_key
create_sftp_connection    · test_sftp_connection
setup_api_key

При повторном живом проходе по всем 622 именам нашлись ещё две категории, которых в первом отчёте не было. Первая — управление пользовательскими базами, потенциально с PII конечных клиентов рекламодателей (это рекламная платформа — «user db» здесь почти наверняка означает аудиторные сегменты для таргетинга):

Code:
list_user_dbs · create_user_db · bulk_upload_replace_user_db
get_user · create_user
list_user_attributes · add_user_list_attribute_values

Вторая — платёжные операции по контрактам:

Code:
list_contract_payments · create_contract_payment · delete_contract_payment

Постскриптум к AdButler: тот же паттерн, другой периметр вокруг self-service — тоже команда за командой
Тем же способом, что и с BorealHost — только initialize/tools/list и один пробный tools/call.

Code:
curl -sS -N -X POST 'https://mcp.adbutler.com/mcp' ... \
  --data-raw '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
| jq '.result.tools | length'
→ 622
| jq '[.result.tools[] | select(.securitySchemes != null)] | length'
→ 0

Вывод: 622 тула, ни у одного нет формальной securitySchemes-декларации. Это хуже, чем у BorealHost, где хотя бы часть write-тулов (update_checkout, service_create-подобные) прямо в тексте описания предупреждала о требовании ключа: у AdButler create_advertiser, create_beacon_signing_key, create_sftp_connection — описания в одну строку («Create a new advertiser» и т. п.) без единого слова про авторизацию ни в схеме, ни в prose. Из всех 622 упоминание auth нашлось только у четырёх тулов, и три из них — про сам механизм ключа, а не про то, что он нужен остальным 618. Дальше: проверить сам роутинг вызовов пробным именем.

Code:
--data-raw '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"__probe__","arguments":{}}}'
→ {"isError":true,"content":[{"text":"MCP error -32602: Tool __probe__ not found"}]}

Вывод: диспетчер долетает до «нет такого тула» без единого запроса на Authorization — сам роутинг не гейтится отдельно от листинга (это по-прежнему не доказательство, что create_advertiser пропустит реальный вызов без ключа — только то, что диспетчер не отбраковывает неавторизованные запросы на подступах к нему). Дальше: есть ли у AdButler свой аналог register() у BorealHost.

Тут пришлось разбираться по схемам, а не живыми вызовами — потому что путь наружу оказался другим. setup_api_key описан так: «Use this if the user already has an AdButler account and API key» — этот тул не выдаёт ключ сам, ему нужен уже существующий. Но рядом обнаружился симметричный self-service путь — create_trial_account → письмо с кодом на реальный email → verify_trial_email, и только тогда «On success, the API key is automatically configured». Вывод по схеме: это заметно более сильный барьер, чем мгновенный register у BorealHost — здесь нельзя получить ключ, не подтвердив владение настоящим почтовым ящиком.

Отдельно стоит call_adbutler_api — универсальный проброс: «Call any AdButler API endpoint directly... The session's configured API key is used automatically». По схеме это буквально универсальный REST-клиент внутри одного MCP-тула — method (любой HTTP-глагол, включая PUT/DELETE), path с плейсхолдерами, path_params, query_params и произвольный JSON body. Теоретический предел этого тула не 622 инструмента, а весь набор эндпоинтов AdButler API целиком, каким бы он ни был на самом деле — если авторизация где-то в цепочке всё же протекает, у неё сразу максимальный, а не точечный blast radius.

Итог по паре кейсов: у обоих операторов протокольный слой (tools/list, роутинг tools/call) одинаково открыт настежь и одинаково без формальной securitySchemes-декларации, но онбординг устроен по-разному. BorealHost выдаёт рабочий ключ мгновенно и без проверки личности; AdButler — только после подтверждения владения email. Это ровно тот тип различия, который стоит проверять у себя: не «есть ли где-то в системе авторизация», а «на каком именно шаге она реально требует доказательства, а не просто текст в описании тула».

Что не подтвердилось: четыре раунда охоты за ещё одним BorealHost

После этих двух кейсов логичный следующий шаг — найти третий такого же калибра. Ушло четыре раунда живой проверки на четырёх разных операторах, и ни один не подтвердился как обход. Ниже — не «мы поискали и не нашли», а реальные команды, реальные ответы и то, что из каждого стало понятно, потому что именно эта часть работы обычно остаётся за кадром в подобных разборах.

Раунд 1 — Ganty (https://ganty.app/api/mcp), PM-SaaS на Gantt-диаграммах. tools/list живьём — 15 тулов, securitySchemes 0/15, у delete_project («Permanently delete a project... This cannot be undone») ни слова про auth. Пробный tools/call на фейковое имя:

Code:
--data-raw '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"__mcp_exposure_auditor_nonexistent_probe_tool__","arguments":{}}}'

JSON:
{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized: invalid or missing API token"}}

Вывод: это не «tool not found», как у BorealHost/AdButler, а честная ошибка авторизации — то есть сама диспетчеризация вызовов гейтится, причём до того, как сервер вообще проверяет, существует ли такой тул. Дальше: проверить, вдруг это специфика именно фейкового имени — позвать настоящий, безобидный read-тул тем же способом.

Code:
--data-raw '{"...","name":"list_workspaces","arguments":{}}'
→ {"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized: invalid or missing API token"}}

Вывод: то же самое на реальном имени тула. Гейт универсальный, не выборочный — карта возможностей открыта, инвокация закрыта. Отбраковал.

Раунд 2 — MainBook (https://mcp.mainbook.ai/mcp), конвертация банковских выписок. Тот же паттерн, короче:

Code:
--data-raw '{"...","name":"__mcp_exposure_auditor_nonexistent_probe_tool__","arguments":{}}'
→ {"error":"invalid_token"}
--data-raw '{"...","name":"list_conversions","arguments":{}}'
→ {"error":"invalid_token"}

Вывод: идентично Ganty — 4 тула, 0 securitySchemes, но диспетчеризация закрыта на обоих типах вызова. Отбраковал.

Раунд 3 — aiconduit (https://mcp.aiconduit.app/mcp), agentic commerce-платформа. Этот раунд был самым долгим и заслуживает отдельного разбора — потому что именно здесь всё выглядело как обход дольше всего.

tools/list — 17 тулов, securitySchemes 0/17, пробный вызов на фейковое имя проходит чисто до "not found" (тот же уровень, что у BorealHost/AdButler — жёсткий фильтр пройден). В самой схеме order_execute.session_token написано дословно: «Session token from agent_authenticate — required whenever agent_id is passed» — а в required этого тула только ["agent_id", "supply_id"]. Зарегистрировал синтетического агента (свой ES256-ключ, никакого PII) через документированный self-service agent_create — получил реальный agent_id и session_token. Дальше — ключевая проверка, специально на копеечном sandbox-товаре ($0.12, провайдер CJ Sandbox, помечен оператором как «not a real purchase path»):

Code:
--data-raw '{"...","name":"payment_mandate","arguments":{"agent_id":"cmt5...","action":"request","supply_id":"e06b91..."}}'
→ {"error":"mandate_scope_required","hint":"Pass scope (maxPerOrder / monthlyCap / categories)..."}

Вывод (ошибочный на этом шаге): вызов без session_token не отбился ошибкой авторизации — похоже на обход. Дальше: добавить недостающее поле scope и посмотреть, пройдёт ли вызов целиком без токена.

Code:
--data-raw '{"...","scope":{"maxPerOrder":1,"monthlyCap":5}}'
→ {"error":"auth_required","hint":"This call passed agent_id but no session_token..."}

Вывод: как только все бизнес-поля на месте — сервер честно потребовал токен. Первая ошибка была не про авторизацию, а про порядок валидации (сервер проверяет обязательные бизнес-поля раньше токена) — артефакт, а не дыра. Перепроверил на order_execute, где лишних полей нет вообще (required только agent_id+supply_id):

Code:
--data-raw '{"...","name":"order_execute","arguments":{"agent_id":"cmt5...","supply_id":"e06b91..."}}'
→ {"error":"auth_required","hint":"..."}

Вывод: чисто, без шума — session_token реально enforced, несмотря на мягкую схему. Обход не подтвердился.

Раунд 4 — Chainstack (https://mcp.chainstack.com/mcp), blockchain-инфраструктура. tools/list — 18 тулов, securitySchemes 0/18, у create_project/get_organization/delete_project ни слова про auth в описании — при том что соседний request_testnet_funds в том же списке содержит развёрнутую инструкцию про API-ключ. Разница выглядела многообещающе. Но create_project/get_organization не принимают вообще никаких параметров-идентификаторов — вся привязка к аккаунту идёт через HTTP-заголовок Authorization. Проверка на своём же, пустом аккаунте:

Code:
--data-raw '{"...","name":"get_organization","arguments":{}}' -H 'Authorization: Bearer <наш ключ>'
→ {"id":"RG-604-959","name":"test12"}
--data-raw '{"...","name":"get_organization","arguments":{}}'   # без заголовка вообще
→ {"error":"Error calling tool 'get_organization': No API key provided.
   Fetch https://mcp.chainstack.com/ (GET) for connection config with API key setup..."}

Вывод: с ключом — реальные данные, без ключа — честный, осмысленный отказ. Обход не подтвердился.

Что это значит в сумме. Четыре раунда, четыре разных оператора, один и тот же результат: полная карта возможностей (имена тулов, структура аргументов, деструктивные операции) утекает анонимно почти всегда, а реальная запись — почти никогда, потому что серьёзные операторы обычно всё-таки ставят настоящую Authorization-проверку на уровне транспорта, даже когда откровенно небрежны в самой MCP-схеме.

Итог

И вот здесь стоит честно признать масштаб того, чего эта работа не покрывает. mcp-exposure-auditor в текущем виде — это ~2000 строк, один источник данных (официальный реестр, то есть только те операторы, которые сами захотели, чтобы их нашли) и пять read-only запросов на цель. Это далеко не потолок того, что можно сделать с той же самой идеей. Тот же классификатор, который сейчас ловит два структурных сигнала (securitySchemes, x402-паттерн), можно докрутить: добавить эвристики на resource-scoping (то самое различие между «схема мягкая» и «рантайм всё равно режет», которое в этой статье четыре раза подряд решало исход кейса), расширить discovery за пределы одного реестра — на произвольные домены, поддомены (mcp., api./mcp/), на устаревший SSE-транспорт отдельно от Streamable HTTP, на серверы, которые в реестр вообще не подавались, потому что их создатели о нём не знали или не захотели светиться. Каждое из этих направлений — не гипотеза, а прямое продолжение уже написанного кода.

Ниша при этом растёт быстрее, чем на неё успевает смотреть кто угодно, включая нас: тысячи новых MCP-серверов в месяц, у каждого — свой самопальный слой авторизации, и почти никто не думал про protocol-level discovery как отдельную поверхность атаки, потому что до MCP такой поверхности у большинства команд просто не существовало. 3903 сервера — это то, что согласилось быть найденным через один-единственный официальный канал; сколько всего remote-эндпоинтов реально стоит за нестандартными путями на случайных доменах, никто не считал, и это не риторика, а честный пробел в методологии, который следующая версия того же инструмента может закрыть. При должной настойчивости — грамотнее фильтровать шум, шире смотреть за пределы одного реестра, точнее отличать «намеренно открыто» от «забыли» — тот же самый скрипт на нескольких тысячах строк способен закрыть кусок этой ниши, который сейчас никто системно не проверяет вообще.

Ссылка на софт — https://duty-files.cc/file/pNTmtFORQnA6o17p/7vWhWUFZX2PRGwS3/mcp-exposure-auditor.zip

МетрикаЗначениеУникальных remote-эндпоинтов из реестра просканировано3903PROTECTED (initialize сам требует auth — не находка)1496 (38.3%)MEDIUM (initialize открыт, все list-методы гейтятся)34INDETERMINATE (сеть/таймаут, чистый сигнал не получен)571CRITICAL (сырой, до исправления классификатора)1807 (46.3%)— из них автоматически отфильтровано как вероятный false positive170 (9.4% от CRITICAL)— остаётся после автофильтра, требует ручного чтения по одному1631Операторов проверено вручную живьём (initialize/tools/list/probe + при возможности — self-service на своём ресурсе)6— подтверждён реальный обход (анонимная запись в прод без единого креда)1 (BorealHost)— полная карта видна анонимно, но диспетчеризация/запись на практике защищена5 (AdButler — не проверялось; Ganty, MainBook, aiconduit, Chainstack — проверено и опровергнуто)
👍26 😁3 😮6 👏7
спасибо что зашифровал архив age и не дал пароль
👍1
age-encryption.org
забавно... 

2тс: для оплаты статьи архив должен быть открытый
Извиняюсь, исправил
👍1
ОтветCuriousGuy

Извиняюсь, исправил

пароль пишется в теме, да и все, если было такое желание
зашифровать внешний файл
👍3

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.