Мок-API

MCP-серверы

Мок MCP-сервера Apify и собственный MCP-сервер APIStend — подключение, инструменты, чем мок отличается от боевого сервера.

На этой странице · 11

MCP (Model Context Protocol) — то, чем ИИ-агент пользуется вместо HTTP-клиента. В стенде два MCP-сервера, и путать их не нужно:

АдресЧто этоКого подменяет
/apify/mcpмок MCP-сервера Apifymcp.apify.com
/mcpсобственный сервер APIStendничего — это сам стенд

Первый нужен тому, кто пишет агента к Apify. Второй — тому, кто пишет агентом интеграцию с любым из четырёх сервисов и хочет, чтобы агент видел каталог методов, а не гадал по названиям.

Мок MCP-сервера Apify

Зачем он нужен, видно из цены боевого вызова: каждый запуск актора у Apify стоит денег и минуты ожидания. Агента же отлаживают прогоном по кругу — десятки запусков подряд, пока не сойдётся разбор ответа.

Подключение — замена одного адреса:

{
  "mcpServers": {
    "apify": {
      "url": "https://apistend.ru/apify/mcp",
      "headers": { "Authorization": "Bearer stend_sk_…" }
    }
  }
}

Как и у боевого сервера, набор инструментов сужается параметром tools — именами через запятую либо категориями actors, docs, runs, storage:

https://apistend.ru/apify/mcp?tools=actors,storage

Что отвечает полностью

ИнструментОткуда ответ
search-actorsснимок магазина: настоящие имена, описания, рейтинги, тарифы
fetch-actor-detailsнастоящая схема входа актора из его последней сборки
call-actorвход проверяется по схеме актора, выдача — по схеме датасета или примерам из readme
get-actor-runзапуск из реестра; чужой runId — отказ, а не выдуманный запуск
get-actor-run-listзапуски песочницы в форме RunShort, с фактической стоимостью usageTotalUsd
get-dataset-itemsстроки того же запуска; чужой датасет — отказ
get-key-value-store-recordмок соответствующего метода
abort-actor-runмок метода прерывания запуска

Форма ответа каждого инструмента — та, что объявлена в его outputSchema, снятой с боевого сервера. Это не педантизм: официальный MCP SDK валидирует structuredContent по схеме, которую сервер сам же отдал в tools/list, и при расхождении отбрасывает ответ целиком — клиент получает «Structured content does not match the tool's output schema», хотя данные в ответе есть. Соответствие держит тест, прогоняющий каждый инструмент против его собственной схемы.

Идентификатор датасета клиент берёт оттуда же, откуда у боевого сервера, — storages.datasets.default.id; отказ приходит текстом без structuredContent, потому что формы для неудачи в схеме не объявлено.

Самое полезное здесь — call-actor. Вход проверяется по настоящей схеме актора, поэтому агент, собравший вызов неверно, узнаёт об этом бесплатно и сразу, а не после платного запуска.

Форму строки даёт то, что автор актора сам сказал о своём результате, — в таком порядке:

  1. схема полей датасета из последней сборки: значения берутся из examples, которые в ней написал автор;
  2. строки результата из readme, если схемы нет: кусок JSON под заголовком «Output» на странице актора. Такие строки отдаются как есть и не размножаются до запрошенного количества — три показанных автором строки это три строки;
  3. пустой массив, если автор не описал результат никак. Придумывать за него поля мок не станет: по несуществующему полю агент напишет разбор, который в бою развалится.

Схема датасета объявлена у 429 акторов снимка, примеры в readme — у 739, вместе форма выхода известна для 874 из 1 200.

Значения товарных полей — это рынок: чужие предложения того же предмета, что и карточка продавца. За скрапером маркетплейса приходят именно за конкурентами, а не за своими же товарами. Строка распознаётся как карточка товара, если рядом с названием стоят цена, артикул или рейтинг; тогда выдача начинает зависеть от входа:

  • queries, query, search, keywords отбирают предмет: «коврик для йоги» находит рынок ковриков, «кофе в зёрнах» — рынок кофе;
  • maxItems, resultsLimit, maxResults, limit задают количество строк.

Что в этом рынке есть:

**Продавцы**у каждой строки свой магазин, юрлицо целиком, ИНН и ОГРН с верной контрольной суммой, свой рейтинг
**Цены**построены от цены своей карточки с разбросом в пределах трети — коридор, медиана и отклонение считаются осмысленно
**Артикулы**из чужого диапазона: собранный конкурент не окажется собственной карточкой продавца
**Связь со своим**предмет тот же, что у карточки песочницы, — сравнивать есть с чем, потому что тот же предмет отдают моки Wildberries и Ozon

Цена в самом каталоге тоже привязана к предмету: коврик стоит как коврик, а не как ноутбук. Пока цена была случайной по всему прайсу, медиана по категории считалась по величинам, которые вместе не встречаются.

Строка, которая карточкой товара не выглядит — пост в соцсети, точка на карте, вакансия, — остаётся ровно такой, какой её показал автор.

Запуск, сделанный через call-actor, запоминается: следующий вызов get-dataset-items по его datasetId вернёт те же строки, а get-actor-run по его runId — тот же запуск. Именно так работает агент: запустил, потом забрал результат отдельным вызовом. Реестр живёт в памяти процесса — запуска на самом деле не было, и хранить его в базе значило бы выдавать образец за состояние. Идентификаторы детерминированы от актора и входа, поэтому после перезапуска повторный вызов даёт те же самые.

Число элементов уважает вход: resultsLimit, maxItems, maxResults и limit. Агент, попросивший пять элементов и получивший три, решил бы, что данные кончились.

Стоимость прогона

get-actor-run-list в снимок инструментов не попал — набор снимался на дату, когда боевой сервер его по умолчанию не отдавал, — но у mcp.apify.com он есть, и объявлен здесь дословно: имя, описание и схема входа взяты у боевого сервера. Схему выхода он для этого инструмента не объявляет, поэтому её нет и тут: придуманная нами, она стала бы законом для клиента, и SDK отбрасывал бы законный ответ боевого сервера при первом же расхождении.

Нужен он ради одного поля. Фактическую стоимость прогона отдаёт только список запусков: в карточке одного запуска usageTotalUsd нет ни у боевого Apify, ни здесь. Клиент, который списывает деньги за запуск, без списка вынужден списывать оценку вместо факта — и расхождение между ними вылезает уже на живых деньгах.

Записи идут в форме RunShort из OpenAPI Apify, и стоимость считается по настоящему тарифу актора из снимка магазина:

Тариф актораКак считается
PRICE_PER_DATASET_ITEM` с ценойчисло элементов × цена за элемент
остальныеcompute units × 0,25 $ — объявленная цена вычислений Apify

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

Что агент узнаёт о песочнице

Модель не догадается сама, что перед ней мок, — ей об этом говорят прямо, в двух местах.

instructions мока — дословный текст боевого mcp.apify.com плюс раздел «APIStend sandbox (not Apify)» в конце. Он объясняет главное: настоящего запуска не было, значения демонстрационные, форма — настоящая; выдача берётся из схемы датасета или из примеров readme, а пустой список означает «автор не описал результат», а не «по запросу ничего не нашлось». Боевая часть текста не переписана: по ней модель выбирает и запускает акторов, и «близко к тексту» она перестала бы быть тем же интерфейсом.

Каждый ответ call-actor несёт ту же мысль дважды: строкой в тексте, который читает модель, и полем _apistend в структуре, которую разбирает код:

_apistendЧто это значит
outputSource: dataset-schemaформа строки — из схемы полей датасета сборки
outputSource: readme-examplesформа строки — из примера, показанного автором в readme
outputSource: noneавтор не описал результат никак, список пуст
values: apistend-catalogзначения — рынок конкурентов вокруг каталога песочницы
values: actor-authorзначения — те, что показал автор актора
matchedQueryнашлись ли в каталоге товары по поисковой фразе запроса

Подчёркивание в имени не случайно: у боевого Apify такого поля нет, и клиент, разбирающий его форму ответа, не примет служебное поле песочницы за своё.

Собственный MCP-сервер APIStend говорит то же самое своими словами: его instructions предупреждают, что данные демонстрационные, а list_services показывает у Apify адрес мока MCP рядом с префиксом REST.

Ключ и поток — как у боевого сервера

mcp.apify.com требует токен на любой запрос: 401 приходит и на GET, и на POST, и на DELETE, ещё до разбора тела. Мок ведёт себя так же, только ключ здесь свой — stend_sk_… песочницы. Форма отказа снята дословно: конверт {"error":"invalid_token","error_description":"…"}, заголовок WWW-Authenticate: Bearer realm="OAuth", error="invalid_token", а на GET — человекочитаемый абзац вместо конверта.

GET открывает поток «сервер → клиент» и держит его: раз в двадцать пять секунд уходит комментарий SSE, через десять минут молчания поток закрывается — клиент переоткрывает его сам, это штатная часть протокола. Сообщений по своей инициативе мок не шлёт: выдумывать события сервера значило бы кормить клиента тем, чего не происходило.

Раньше на GET приходило 405 Method Not Allowed, и клиенты уходили в цикл переподключения: за день это три с половиной тысячи запросов, из которых полторы тысячи — один и тот же список инструментов по 80 КБ.

Вызовы видны в журнале

Обращение по MCP — такой же вызов песочницы, как запрос к шлюзу, и попадает в журнал кабинета: в строке видно, что именно звал агент — /apify/mcp tools/call call-actor, — а в карточке лежат тело запроса и ответа. Мок mcp.apify.com пишется под кодом сервиса apify, собственный сервер APIStend — под apistend, так что в фильтре «Сервис» они разделены.

Записать вызов можно только в песочницу, а её называет ключ. Вызов без Authorization: Bearer stend_sk_… выполняется, но в журнал не попадает — приписать его некому. Чтобы это не выглядело пропажей, такой ответ несёт заголовок X-APIStend-Log: skipped-no-sandbox-key.

Что честно отказывает

search-apify-docs, fetch-apify-docs, apify--rag-web-browser и apify--web-fetch в песочнице не выполняются. Все четыре обращаются к живой сети — к документации Apify или к произвольной странице. Песочница обязана работать без интернета, а подсунуть агенту правдоподобный текст страницы, которой он не видел, — ровно то, чего этот проект не делает.

report-problem принимает обращение и сообщает, что наружу оно не ушло.

Откуда взяты описания инструментов

Имена, описания, схемы входа и выхода, serverInfo и instructions сняты дословно с mcp.apify.com и лежат в specs/apify/mcp-tools.json вместе с датой снимка. Переписанные «близко к тексту», они перестали бы быть тем же интерфейсом. Обновить: APIFY_TOKEN=… ./scripts/vendor-apify-mcp-tools.sh. Без токена снимается только анонимный набор из четырёх инструментов — больше боевой сервер без токена не отдаёт.

Собственный MCP-сервер APIStend

Даёт агенту то же, что кабинет даёт человеку.

Включается в настройках

Доступ выключен по умолчанию, и включает его пользователь: кабинет, раздел Настройки → Доступ для ИИ-агента (MCP). Там же лежат два готовых блока — конфигурация клиента и инструкция для агента, — оба копируются целиком.

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

Почему выключено по умолчанию. Агент, которому дали ключ, вызывает моки и читает журнал запросов песочницы, а в журнале лежат тела запросов, написанные человеком. Такое включают осознанно, а не обнаруживают включённым.

Каталог методов при этом открыт и до включения: list_services, search_catalog и describe_method работают без ключа вовсе. В них нет ничего личного, а агенту каталог нужен как раз до того, как у него появится доступ к песочнице.

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

{
  "mcpServers": {
    "apistend": {
      "url": "https://apistend.ru/mcp",
      "headers": { "Authorization": "Bearer stend_sk_…" }
    }
  }
}

Инструкция для агента

Второй блок на том же экране — готовый текст для системного промпта агента или для AGENTS.md проекта: когда пользоваться стендом, в каком порядке вызывать инструменты, чему верить в ответе по полю responseSource и чего мок не делает.

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

ИнструментЧто делает
list_servicesчем что подменяется: адреса, авторизация, лимиты, число методов
search_catalogпоиск метода по пути, названию и описанию
describe_methodпараметры, сценарии, боевой хост, дата снимка, пример ответа
call_mockвызов мока с боевыми заголовками сервиса
recent_requestsжурнал запросов песочницы

list_services и search_catalog работают без ключа — это открытый каталог. call_mock и recent_requests требуют ключ песочницы и уважают его область действия: ключ только на Ozon не вызовет мок Wildberries.

Ключ тот же самый, stend_sk_…, что и у мок-шлюза. Отдельного вида токена не заводится намеренно: у ключа уже есть и песочница, и список разрешённых сервисов, и отзыв.

Зачем агенту каталог

Живую документацию Ozon и Wildberries агент не откроет — обе закрыты антиботом. Каталог APIStend уже содержит разобранные методы с примерами ответов, и describe_method отдаёт их напрямую. Поле responseSource при этом говорит, чему верить: example — значения из документации сервиса, schema — форма настоящая, значения выдуманы, generic — только то, что клиент не упадёт на разборе.

Транспорт

Оба сервера говорят по Streamable HTTP, и поведение снято с живого mcp.apify.com, а не выведено из спецификации:

ЧтоКак отвечает
Запрос с `id200, content-type: text/event-stream, один кадр event: message
Уведомление без `id202 и пустое тело
initializeвыдаёт заголовок Mcp-Session-Id
Accept` без обоих типов406 и ошибка -32000 — обычным JSON, не кадром SSE
DELETE200
Незнакомая сессияне ошибка: сервер заводит новую

Последний пункт стоит отметить. Клиент, переживший перезапуск сервера, у боевого Apify продолжает работать; мок, отвечающий ему 404, сломал бы ровно тот сценарий, ради которого мок и берут.

Заголовки Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-Id и WWW-Authenticate перечислены в Access-Control-Expose-Headers: клиентом MCP всё чаще оказывается расширение браузера, а не локальный процесс.