Skip to main content
Glama

aiMCPGate

Русская версия — README_RU.md.

Шлюз / прокси для MCP-серверов (Model Context Protocol), написанный на Go. Он представляется MCP-клиенту (Claude Code, Cursor и т. д.) как один MCP-сервер, а под капотом мультиплексирует вызовы между несколькими вышестоящими MCP-серверами, агрегирует их инструменты, промпты и ресурсы в единый каталог и журналирует каждый вызов.

Статус: MVP завершён (этапы 0–6) + пост-MVP этапы 7–18 выпущены, последний релиз v0.5.0. Фаза 1 — мультиплексирование stdio-апстримов за stdio-эндпоинтом с журналом вызовов; Фаза 2 — HTTP/SSE-транспорт на стороне клиента, HTTP-апстримы, CLI-просмотр журнала (mcp-gate logs); конвейер релизов (goreleaser, кросс-компиляция для linux/darwin/windows × amd64/arm64, без CGO). После MVP добавлены автоматический перезапуск апстримов, горячая перезагрузка конфигурации, фильтрация/переименование инструментов, doctor, а в v0.3.0 — полная агрегация prompts/resources/ resources/templates/completion, ping, пересылка прогресса и настоящая отмена, рассылка logging/setLevel, лимиты вызовов на апстрим (ограничение скорости / конкурентность / усечение результата / таймаут), ленивый каталог и пагинация tools/list, а также SSE-потоки сервер→клиент как на стороне клиента, так и на стороне апстрима. v0.4.0 завершил направление сервер→клиент: все три метода, инициируемые сервером, — elicitation/create, sampling/createMessage и roots/list — проксируются во всех четырёх комбинациях транспортов (stdio или HTTP на стороне клиента × stdio или HTTP на стороне апстрима); шлюз теперь объявляет апстриму ровно те возможности, которые объявил его собственный клиент, вместо безоговорочного {}; HTTP-транспорт получил серверные сессии Mcp-Session-Id с завершением через DELETE /mcp. v0.5.0 добавляет наблюдаемость для оператора (этап 18): восемь видов событий — сбои запуска апстримов и отказ супервизора, отброшенные уведомления и запросы сервер→клиент, HTTP-апстрим без GET SSE, коллизии каталога и плохие URI-шаблоны, а также результат, молча обходящий max_result_bytes, — теперь попадают в журнал вызовов (mcp-gate logs) вместо stderr, которым обычно владеет MCP-клиент; разбор конфигурации стал строгим (неизвестные/с опечатками ключи являются фатальными). Он также закрывает клиентскую половину истории защиты/усечения: вызов tools/call, отклонённый ограничителем скорости или конкурентности, теперь возвращает собственный код ошибки JSON-RPC -32029 с машиночитаемым data: {"retryable":true,"reason":...} вместо неотличимого -32603, а нетекстовый результат, обходящий max_result_bytes, несёт маркер result._meta (content остаётся байт-в-байт нетронутым). Наконец, auth_token, ссылающийся на неустановленную переменную окружения, теперь отказывается запускать шлюз вместо молчаливого отключения HTTP-аутентификации.

Обновление до v0.5.0 — три изменения поведения, ни одно не затрагивает сам формат файла конфигурации:

  • Разбор конфигурации теперь строгий. Конфиг с неизвестным или с опечаткой ключом верхнего уровня или на уровне апстрима, который раньше молча игнорировался, теперь не загружается. Исправьте имя ключа (ошибка называет его) или удалите его.

  • auth_token: ${VAR} с неустановленной VAR теперь отказывается запускаться, называя переменную. Раньше он молча становился пустым токеном — что на HTTP-шлюзе полностью отключало проверку bearer без предупреждения. Установите переменную (или передайте --env-file), либо удалите auth_token, чтобы намеренно работать без аутентификации.

  • Журнал вызовов (log_file / calls.jsonl) получил второй вид записи, "kind":"event", наряду с существующими записями вызовов. Бинарник версии v0.4.0 или старше, читающий журнал v0.5.0, отображает строку события как разреженную запись ERR, а не падает — читайте журнал тем же или более новым бинарником, чем тот, который его записал.

Обновление до v0.4.0: изменений в файле конфигурации нет, но есть два заметных изменения поведения в HTTP-режиме — идентификатор сессии теперь обязателен на POST /mcp после initialize (заголовок возвращается в ответе на initialize), а реестр апстримов запускается лениво при первом реальном MCP-запросе вместо запуска при старте процесса.

Не реализовано: политика доступа на уровне клиента.

Релизы

Кросс-платформенные бинарники собираются через goreleaser (.goreleaser.yaml): linux/darwin/windows × amd64/arm64, без CGO, версия встраивается через -ldflags -X main.version=..., контрольные суммы попадают в SHA256SUMS. Локальный пробный запуск: goreleaser release --snapshot --clean.

Related MCP server: mcpstead

Установка из MCP-реестра

Помимо сырых бинарников релиза, шлюз поставляется как OCI-образ на GitHub Container Registry и как npm-обёртка — два формата, из которых устанавливают MCP-реестры.

Docker:

docker run --rm -i -v $(pwd)/config.yaml:/config.yaml ghcr.io/akomyagin/aimcpgate serve

-i обязателен: шлюз общается по MCP через stdio, поэтому клиент должен держать stdin открытым (без этого контейнер видит EOF и немедленно завершается). У образа нет собственной конфигурации, поэтому смонтируйте свою — в примере выше она монтируется на путь по умолчанию /config.yaml; любой другой путь работает с serve -c.

Чтобы воспроизвести проверку песочницы реестра (Glama.ai и т. п.) без какого-либо реального апстрима, используйте демо-конфиг, встроенный в образ — именно эту команду должна запускать песочница:

docker run --rm -i ghcr.io/akomyagin/aimcpgate serve -c /demo.config.yaml

npx (при первой установке загружает предварительно собранный бинарник для вашей платформы и проверяет его SHA256-контрольную сумму):

npx aimcpgate serve -c ./config.yaml

Политика образа: OCI-образ содержит только бинарник mcp-gate — никаких сред выполнения для stdio-апстримов (нет node/npx, python, оболочек). Если ваш конфиг запускает stdio-апстрим-серверы, расширьте образ самостоятельно и установите то, что им нужно; HTTP-апстримы работают из коробки (сертификаты CA включены).

Демо-конфиг: demo.config.yaml и скрытая подкоманда __demo-echo существуют только для того, чтобы песочницы реестров (Glama.ai) могли интроспектировать шлюз без реального апстрима — никогда не используйте их в реальном развёртывании.

Запуск CLI-команд внутри контейнера

doctor, catalog, call и logs — это то, как оператор проверяет развёртывание. Три факта определяют, как их нужно вызывать внутри контейнера:

  1. Бинарник — это /mcp-gate, и его НЕТ в $PATH. Dockerfile делает COPY mcp-gate /mcp-gate и ENTRYPOINT ["/mcp-gate"] — ничто не помещает его в путь поиска (проверьте Dockerfile, если это когда-либо покажется неправильным). Поэтому очевидная форма не работает:

    $ docker exec mcp-gate mcp-gate catalog -c /config.yaml
    OCI runtime exec failed: exec failed: unable to start container process: exec: "mcp-gate": executable file not found in $PATH

    Вместо этого используйте абсолютный путь — это единственное отличие.

  2. Образ distroless, поэтому оболочки нет вообще. База — gcr.io/distroless/static-debian12:nonroot, которая содержит бинарник и сертификаты CA и ничего больше. docker exec mcp-gate sh -c '…' не работает точно так же, как sh просто отсутствует, и нет ls/cat, чтобы осмотреться. Держите конвейеры, глоббинг и перенаправление на ХОСТЕ команды.

  3. docker exec запускает НОВЫЙ процесс; он не опрашивает работающий serve. doctor, catalog и call строят собственный реестр, открывают собственные соединения с апстримами, сообщают результат и завершаются. Поэтому их вывод — это доступность апстримов прямо сейчас, а не состояние живого шлюза: если работающий процесс потерял апстрим и удалил его из своего каталога, эти команды этого не покажут. Они также не засоряют журнал вызовов — они работают с отключённым журналированием, поэтому call, сделанный таким образом, не появляется в logs.

docker exec mcp-gate /mcp-gate version
docker exec mcp-gate /mcp-gate doctor  -c /config.yaml
docker exec mcp-gate /mcp-gate catalog -c /config.yaml
docker exec mcp-gate /mcp-gate call demo__echo '{"text":"hi"}' -c /config.yaml
docker exec mcp-gate /mcp-gate logs    -c /config.yaml --tail 50

Команды предполагают контейнер, запущенный в отсоединённом режиме и с именем, например docker run -d --name mcp-gate … — в отличие от примера с docker run --rm -i … выше, который завершается, как только его stdio-клиент отключается, и не оставляет ничего, к чему мог бы обратиться docker exec. Конфиг предполагается смонтированным на путь по умолчанию /config.yaml, как в том же примере; demo__echo заменяет инструмент из вашего собственного каталога. Несколько предостережений:

  • logs — исключение из факта 3: он читает ФАЙЛ журнала, который пишет работающий шлюз, поэтому он отражает живой процесс. Для этого требуется, чтобы log_file в смонтированном конфиге указывал на путь, видимый внутри контейнера, и том, смонтированный туда — иначе журнал уходит в stderr контейнера (т. е. в docker logs), и mcp-gate logs нечего читать. -c сообщает ему, где находится журнал; --file переопределяет его.

  • Это действительно про HTTP-режим. В stdio-режиме MCP-клиент порождает и владеет контейнером, поэтому обычно нет долгоживущего контейнера, в который можно было бы выполнить exec. Шлюз, который можно инспектировать, — это запущенный отдельно (docker run -d --name mcp-gate …) с transport: http.

  • HTTP-режиму нужен нестандартный listen_addr. По умолчанию — 127.0.0.1:28080 — loopback ВНУТРИ контейнера, недоступный с хоста даже с -p. Установите listen_addr: 0.0.0.0:<port> в конфиге; тогда шлюз откажется запускаться без auth_token, намеренно («HTTP-эндпоинт был бы доступен из сети без аутентификации»).

Зачем

Активный пользователь MCP обычно имеет несколько настроенных серверов (файловая система, GitHub, поиск, собственные), каждый из которых дублируется в конфиге каждого клиента. aiMCPGate даёт вам:

  • Одну точку входа — один MCP-эндпоинт вместо N записей в конфиге клиента.

  • Один каталог — инструменты и промпты каждого вышестоящего сервера объединяются (с пространствами имён <upstream>__<tool>, чтобы имена никогда не сталкивались), плюс их ресурсы и шаблоны ресурсов (адресуются по URI, поэтому никогда не переименовываются).

  • Журнал вызовов — какой апстрим, какой инструмент, когда, успех/неудача. Это добавленная ценность поверх «просто прокси».

Сольный пет-проект: приоритет — изучение Go (конкурентность, os/exec, JSON-RPC 2.0, транспорты stdio и HTTP/SSE). Стоимость — $0/месяц по умолчанию (локальный процесс), без телеметрии.

Как это работает (кратко)

MCP client ──stdio/HTTP──▶ aiMCPGate ──JSON-RPC──▶ upstream A (stdio)
                              │        ├─────────▶ upstream B (stdio)
                          call log     └─────────▶ upstream C (http, Phase 2)

MVP (две фазы)

  • Фаза 1 — мультиплексирование 2+ stdio-апстримов за одним stdio- эндпоинтом (тем же транспортом, на котором говорит Claude Code) плюс базовое журналирование.

  • Фаза 2HTTP/SSE-транспорт, HTTP-апстрим-серверы, просмотр журнала (CLI-версия была построена; веб-представление было намеренно отброшено), опционально политика доступа — это было рассмотрено и отклонено.

Сборка

export PATH="$HOME/sdk/go/bin:$PATH"   # if go isn't already on PATH
go build ./...
go vet ./...
go test -race ./...

go run ./cmd version

Использование

# stdio mode (the client launches the gateway as a subprocess):
mcp-gate serve --config ./config.yaml

# http mode (transport: http in the config) — endpoint at http://<listen_addr>/mcp;
# every request after initialize carries the issued Mcp-Session-Id (see below):
mcp-gate serve --config ./config-http.yaml

# check every enabled upstream once (launch → handshake → tools/list) and print
# a per-upstream OK/FAIL table; exit code is non-zero if any upstream failed
# (scriptable for CI/cron), no auto-restart, no call logging — one pass then exit.
# It also WARNs (POSIX only) if config.yaml or --env-file is readable by
# group/others — either may hold a secret literal, and nothing else checks:
mcp-gate doctor --config ./config.yaml

# call one aggregated tool once from the shell (single bring-up, no supervisor —
# the fastest way to debug a config, a filter or a rename without a live client):
mcp-gate call github__search_repositories '{"query":"mcp"}' --config ./config.yaml

# report the aggregated catalog size per upstream (tools / bytes / ~tokens) plus
# the heaviest individual tools — the data behind allow-list / strip decisions:
mcp-gate catalog --config ./config.yaml --top 20

# view the journal — tool calls AND operator events (last 50 lines; filter by
# upstream/tool/status):
mcp-gate logs --file ./logs/calls.jsonl --tail 50
mcp-gate logs --config ./config.yaml --upstream github --status err
# show ONLY the operator events (see "Operator events" below):
mcp-gate logs --config ./config.yaml --events
# keep watching the log as it grows, or aggregate it instead of listing records
# (--follow and --stats are mutually exclusive):
mcp-gate logs --config ./config.yaml --follow
mcp-gate logs --config ./config.yaml --stats

# generate a random auth token (for the HTTP transport) and see how to wire it in:
mcp-gate token --generate
# print the auth token currently set in the config:
mcp-gate token --config ./config-http.yaml

# print ready-to-paste MCP client config snippets (Claude Code / Cursor / Claude
# Desktop) for whichever transport the config uses: a launch command for stdio, or
# the endpoint URL plus the Bearer header (when auth_token is set) for http:
mcp-gate client-config --config ./config.yaml

# print a SKILL.md teaching an agent how to use the aggregated catalog
# (built-in text by default; overridable via skill_file in the config):
mcp-gate skill > .claude/skills/mcp-gate/SKILL.md

# shell completions (cobra's built-in command; the release archives also ship
# pre-generated ones):
mcp-gate completion bash > /etc/bash_completion.d/mcp-gate

Все команды, кроме token --generate, completion и skill (которая возвращается к встроенному руководству), загружают конфиг: передайте --config или поместите config.yaml рядом с бинарником (см. Конфигурацию ниже).

serve, doctor, call и catalog также принимают --env-file ./.env — минимальный парсер KEY=VALUE, применяемый до загрузки конфига, так что ссылки ${VAR} внутри конфига разрешаются из этого файла. Реальное окружение процесса всегда имеет приоритет над файлом.

HTTP-сессии (Mcp-Session-Id)

В http-режиме шлюз работает с потоковыми HTTP-сессиями: ответ на initialize несёт заголовок Mcp-Session-Id, и каждый последующий запрос — POST, GET SSE-поток, DELETE — должен отправлять этот заголовок обратно. Без него ответ — 400; с неизвестным или истёкшим идентификатором — 404, что говорит клиенту инициализироваться заново. Сессия освобождается через DELETE /mcp (204) или через 30 минут без запросов — открытый SSE-поток считается активностью и поддерживает её живой.

MCP-клиенты делают всё это за вас. Для ручных вызовов curl возьмите заголовок из ответа на initialize и верните его:

SID=$(curl -sD - -o /dev/null -X POST http://127.0.0.1:28080/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \
  | tr -d '\r' | awk -F': ' '/^[Mm]cp-[Ss]ession-[Ii]d/{print $2}')

curl -s -X POST http://127.0.0.1:28080/mcp \
  -H 'Content-Type: application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

curl -s -X DELETE http://127.0.0.1:28080/mcp -H "Mcp-Session-Id: $SID"

Сессия также делает журнал вызовов честным: каждый вызов аудируется под clientInfo сессии, которая его сделала, поэтому несколько HTTP-клиентов различаются в calls.jsonl, а не делят одно пустое поле client.

Запросы сервер→клиент по HTTP (elicitation, sampling, roots)

Когда вышестоящая система (upstream) задаёт вопрос во время вызова — elicitation/create, sampling/createMessage, roots/list — вопрос доставляется как SSE-событие на GET /mcp поток одной сессии, а клиент отвечает обычным POST-запросом, несущим JSON-RPC-ответ с тем же id и тем же Mcp-Session-Id. Отвечать на вопрос может только та сессия, которой он был адресован; ответ от любой другой сессии игнорируется. Если никто не объявил эту возможность с открытым потоком, вышестоящей системе отказывают сразу же в форме, предписанной спецификацией ({"action":"decline"} для elicitation, -32601 для двух других), а не оставляют ждать таймаута — то же самое происходит, если сессия завершается, пока вопрос остаётся без ответа.

Три следствия, о которых стоит знать:

  • Вышестоящие системы узнают о возможностях ПЕРВОГО клиента, который инициализируется, и этот набор фиксирован на всё время жизни процесса. В MCP 2025-06-18 нет повторного согласования, поэтому второй клиент, объявляющий больше, не может изменить уже состоявшиеся рукопожатия — вышестоящей системе никогда не обещают возможность от имени клиента, о котором ей не сообщали.

  • Вышестоящие системы запускаются по первому запросу, который в них нуждается, а не когда шлюз привязывает свой порт. Именно это вообще делает возможным описанное выше объявление: рукопожатие должно произойти после того, как клиент сказал, что он поддерживает. Если вышестоящие системы не могут запуститься, клиент получает JSON-RPC -32603, и шлюз завершается с ошибкой, как и раньше, когда он запускал их с самого начала.

  • Вопрос уходит клиенту, объявившему возможность, — не обязательно тому, чей вызов его спровоцировал. Маршрутизация идёт по объявленной возможности, и среди подходящих сессий побеждает самая недавно активная; запрос от вышестоящей системы не несёт никакой информации о том, какому вызывающему он принадлежит. При одном клиенте (обычный случай) это незаметно, но если запущено два, форма, поднятая вызовом tools/call одного клиента, может появиться в интерфейсе другого.

Сторона вышестоящей системы того же обмена тоже работает через HTTP: удалённый MCP-сервер, доступный по url:, может задать свой вопрос как SSE-кадр — либо в своём долгоживущем GET-потоке, либо вплетённым в поток, отвечающий на один из собственных POST-запросов шлюза, — именно туда SDK-серверы помещают elicitation/create, поднятый внутри tools/call. Шлюз проксирует его через тот же конвейер и отправляет ответ клиента обратно одним обычным POST-запросом, несущим JSON-RPC-ответ под собственным id запроса сервера. Такой вышестоящей системе сообщают возможности клиента шлюза по той же честной политике, что и stdio-системе — возможность предлагается только тогда, когда собственный клиент шлюза её объявил, а doctor/call/catalog, у которых вообще нет клиента, продолжают объявлять ровно {}. Ответный POST не повторяется: вышестоящая система, не получившая его, полагается на собственный таймаут.

Операторские события в журнале

Журнал в log_file содержит два вида строк: по одной на каждый вызов инструмента и по одной на каждое операторское событие — состояние шлюза, о котором вы иначе никогда бы не узнали. В stdio-режиме MCP-клиент владеет терминалом, поэтому stderr шлюза для вас невидим, и несколько таких условий раньше логировались только на уровне отладки. Теперь они пишутся в тот же файл, который читает mcp-gate logs:

Событие

Что оно означает

upstream_start_failed

Вышестоящая система так и не поднялась; её инструменты отсутствуют в каталоге.

upstream_gave_up

Супервизор перестал перезапускать вышестоящую систему (попытки исчерпаны, перезапуск отключён перезагрузкой или нет канала живости) и убрал её из каталога.

notification_dropped

Буфер подписчика был полон, поэтому пересылаемое уведомление было отброшено — пересылка неблокирующая по замыслу.

server_request_dropped

Вышестоящая система запросила что-то, на что мог ответить только клиент (elicitation/sampling/roots), и ни один транспорт не взял вопрос, поэтому вызов инструмента был отклонён от её имени.

sse_stream_unavailable

HTTP-вышестоящая система не предлагает GET SSE-поток, поэтому её tools/list_changed не придёт, пока шлюз не перезапустится.

catalog_collision

Две записи претендовали на одно и то же имя инструмента/промпта или URI ресурса, видимое клиенту; победил первый, проигравший скрыт от клиента.

catalog_bad_template

Шаблон URI ресурса не компилируется: он показан клиенту, но никогда не сможет совпасть при чтении.

result_truncation_skipped

Результат превысил max_result_bytes, но в нём не было усекаемого текста (например, только изображения), поэтому он прошёл целиком.

События появляются вперемешку с вызовами, помеченные EVT; mcp-gate logs --events показывает только их, а --stats получает таблицу по каждому событию. --tool и --status — фильтры только для вызовов, поэтому события исключаются, пока задан любой из них (--upstream применяется к обоим). Одно следствие, о котором стоит знать: notification_dropped не называет вышестоящую систему — сброс является свойством подписчика, чей буфер был полон, а не того, кто отправил уведомление, — поэтому --upstream X никогда его не покажет. Ищите его без этого фильтра. Повторные сбросы объединяются: первый пишется сразу, последующие в течение минуты учитываются в count= следующей строки для этого ключа, а остаток сбрасывается при завершении работы. Строка, несущая такой накопленный хвост, сообщает об этом в своём detail=, называя время самого старого вхождения, которое она вбирает, — собственный штамп времени строки является самым новым, так что вместе они ограничивают, когда на самом деле произошла вспышка.

Два практических замечания:

  • Задайте log_file. Если он пуст, журнал идёт в stderr, который в stdio-режиме принадлежит MCP-клиенту — события писались бы туда, где вы их не увидите.

  • Читайте журнал тем же (или более новым) бинарником, который его писал. События несут поле "kind", которого старые версии не знают, поэтому mcp-gate logs из ≤ v0.4.0 отображает их как разреженные, почти пустые записи.

Ничего из этого не видно MCP-клиенту: никакие коды ошибок, тела результатов или возможности не изменились — события идут только в журнал.

Вызов, который шлюз не смог маршрутизировать, — это не событие, а обычная строка CALL с ошибкой. Клиент, запрашивающий имя инструмента, которого не предоставляет ни одна вышестоящая система, получает CallRecord, как и любой другой, с колонкой upstream, установленной в сторожевой (unrouted); mcp-gate logs --upstream '(unrouted)' выбирает ровно эти строки и ничего больше. Второй, отличный случай выглядит почти так же, но называет реальную вышестоящую систему: маршрут существует (инструмент есть в каталоге), но соединение с вышестоящей системой пропало (перезапускается или отброшено) — такая строка несёт настоящее имя вышестоящей системы, поэтому фильтруйте её с помощью --upstream <имя> как обычно, а не через сторожевой маркер.

Перезагрузка конфигурации (SIGHUP)

Шлюз перезагружает свою конфигурацию на лету по SIGHUP — без перезапуска, без разрыва клиентского соединения. Отредактируйте config.yaml и отправьте сигнал:

kill -HUP $(pgrep -f 'mcp-gate serve')

При перезагрузке шлюз сравнивает новую конфигурацию с работающими вышестоящими системами и применяет минимальное изменение: вновь добавленные вышестоящие системы запускаются, удалённые (или с enabled: false) останавливаются, вышестоящие системы, у которых изменились поля запуска (command/args/url/env/headers), перезапускаются, а вышестоящие системы, у которых изменился только фильтр инструментов (allow/deny/rename или правила проекции каталога strip_annotations/strip_output_schema/max_description/describe), перепроецируются без какого-либо перезапуска. Лимиты вызовов (rate_limit, max_concurrent, max_result_bytes, call_timeout — глобальные или для конкретной вышестоящей системы) также применяются на лету: они никогда не требуют перезапуска, следующий вызов просто использует новые значения. Неизменённые вышестоящие системы продолжают работать нетронутыми. Плохая правка (неверный YAML, неудачная валидация) логируется и игнорируется — текущая работающая конфигурация остаётся в силе, так что опечатка никогда не обрушит шлюз.

Поведенческое замечание: поскольку шлюз устанавливает обработчик SIGHUP, SIGHUP больше не завершает процесс так, как это сделало бы системное поведение по умолчанию. Чтобы остановить шлюз, используйте Ctrl-C, SIGINT или SIGTERM.

SIGHUP работает только в Unix. В Windows — или где угодно, где вы предпочли бы не отправлять сигналы, — используйте вместо этого опциональную альтернативу с опросом:

mcp-gate serve --config ./config.yaml --watch-config        # bare flag = poll every 2s
mcp-gate serve --config ./config.yaml --watch-config=10s    # note the "=", not a space

Он снимает отпечаток файла конфигурации через этот интервал и применяет тот же путь перезагрузки, что и SIGHUP. Работать вместе с обработчиком SIGHUP безопасно.

Наблюдатель сравнивает mtime и размер файла и ждёт, пока этот отпечаток повторится на следующем тике, прежде чем читать файл. Именно это делает двухэтапное сохранение (усечение, затем заполнение) безопасным на практике: писателю придётся удерживать файл в полузаписанном состоянии дольше, чем полный интервал опроса, чтобы обмануть проверку. Цена — задержка: перезагрузка происходит в течение максимум двух интервалов опроса (до 4 секунд при стандартных 2 секундах).

В stdio-режиме вышестоящие системы поднимаются по первому запросу клиента, поэтому правка, сделанная до подключения любого клиента, ещё не может быть применена. Наблюдатель сохраняет эту правку и повторяет попытку при каждом опросе, пока шлюз не поднимется, а затем применяет её — вам не нужно сохранять файл второй раз, чтобы он вступил в силу. Правка, отклонённая окончательно (неразбираемый YAML или защита от отсутствия upstreams ниже), сообщается один раз и не повторяется.

В качестве подстраховки для обоих триггеров перезагрузка, в новой конфигурации которой вообще нет upstreams, отклоняется и логируется: это признак полузаписанного файла, и её применение разрушило бы все работающие вышестоящие системы. Чтобы намеренно удалить все вышестоящие системы, перезапустите шлюз. Явный enabled: false на это не влияет — отключение последней вышестоящей системы по-прежнему применяется.

Конфигурация

Без --config шлюз ищет config.yaml рядом со своим собственным бинарником (например, если mcp-gate установлен в /etc/gate/, он ищет /etc/gate/config.yaml — независимо от рабочего каталога, из которого был запущен). Если этого файла нет и --config тоже не передан, он выдаёт явную ошибку вместо запуска пустого шлюза. Относительные пути внутри конфигурации (log_file, skill_file, debug_payload_log) разрешаются относительно каталога самого файла конфигурации, а не текущего рабочего каталога.

Неизвестные ключи — это ошибка запуска. Конфигурация разбирается строго: опечатанный или нераспознанный ключ останавливает шлюз с именем ключа и его номером строки, а не молча игнорируется, как раньше. Конкретный выигрыш: опечатка в enabled больше не может оставить вышестоящую систему тихо работающей. Пользовательские ключи x- для заметок тоже отклоняются — чтобы поделиться блоком, поставьте YAML-якорь на первую реальную вышестоящую систему и слейте его (<<: *anchor) в остальные; якоря и ключи слияния работают как обычно.

Вышестоящая система включена по умолчанию: опустите enabled: полностью, и она запустится, как и любая другая. Чтобы не включать её в шлюз, не удаляя её конфигурацию, явно отключите её с помощью enabled: false — тогда она не появится ни в tools/list, ни в таблице mcp-gate doctor. Осторожно: enabled: без значения (или enabled: null) читается как опущено, поэтому закомментирование значения оставляет вышестоящую систему работающей — только буквальное false отключает её.

Примечание: поиск «рядом с бинарником» использует путь запущенного исполняемого файла. При go run ./cmd ... этот исполняемый файл — одноразовая сборка во временном каталоге, поэтому поиск по умолчанию не найдёт ваш config.yaml — передавайте --config явно при использовании go run или запускайте собранный бинарник.

Полный пример со всеми полями — config.example.yaml. Набор вышестоящих серверов объявляется в YAML; секреты (токены) передаются через env/.env (подстановка ${VAR} при загрузке), никогда не коммитятся в конфиг. Каждый вышестоящий сервер задаёт ровно один из command (stdio-подпроцесс) или url (HTTP-сервер, Streamable HTTP) — тип соединения определяется автоматически.

Незаданные ссылки ${VAR} ведут себя по-разному в зависимости от поля:

  • auth_token, ссылающийся на незаданную переменную, прерывает запуск, называя переменную — пустой auth_token молча отключил бы HTTP-проверку bearer-токена, поэтому такому нельзя позволять происходить незаметно. Чтобы работать без аутентификации, удалите ключ auth_token целиком.

  • Незаданная переменная в env/headers вышестоящего сервера не является ошибкой: значение становится пустым, и отсутствующий секрет позже проявится как 401 от этого вышестоящего сервера. Шлюз сообщает об этом заранее — событие unresolved_secret_var в журнале (mcp-gate logs) и строка WARN в mcp-gate doctor.

  • В stdio-режиме mcp-gate client-config предупреждает (в stderr), что переменные окружения оператора не наследуются MCP-клиентом, который запускает шлюз в собственном окружении — задайте их там, где клиент его запускает.

По умолчанию каждый stdio-вышестоящий сервер наследует полное окружение процесса шлюза — включая секреты и auth_token, которые --env-file загрузил для других вышестоящих серверов. Установите env_isolation: strict (глобально), чтобы каждый stdio-вышестоящий сервер вместо этого получал только минимальную базу (PATH, HOME, TMPDIR и их Windows-эквиваленты) плюс собственный блок env:, который всегда доходит до дочернего процесса. Рекомендуется, когда вы запускаете вышестоящие серверы из открытых реестров (пакеты, запускаемые через npx, которым вы не полностью доверяете). Переменную шлюза, которую строгий вышестоящий сервер должен видеть, перечислите явно в его env: (например, PYTHONPATH: ${PYTHONPATH}). Значение читается в момент запуска: работающий вышестоящий сервер сохраняет окружение, с которым он стартовал, до своего (пере)запуска.

Отдельно и всегда включено: как только шлюз развернул секрет ${VAR}, он знает точное значение и заменяет его на *** в свободном тексте, видимом оператору — в журнале (mcp-gate logs) и журналах сбоев. Если секреты не настроены, это ничего не стоит.

Секрет, передаваемый по обычному, незашифрованному HTTP на не-loopback-хост, получает WARN при запуске и строку в mcp-gate doctor (никогда не жёсткий сбой — открытый текст в доверенной инфраструктуре, например в VPN или изолированной LAN, может быть осознанным выбором, который шлюз не может переопределить из одного конфига): непустой auth_token, привязанный за пределы loopback, или headers вышестоящего сервера, отправляемые на не-https:// не-loopback url. Сам TLS не встроен в шлюз — поставьте перед ним TLS-терминирующий обратный прокси, если он нужен, та же схема уже рекомендована для взаимного TLS.

transport: stdio            # stdio (Phase 1) | http (Phase 2)
listen_addr: "127.0.0.1:28080"  # only used for transport: http; loopback by default
# auth_token: ${AIMCPGATE_TOKEN}  # required if you widen listen_addr past loopback;
#                                 # the variable must be set or startup fails
log_file: ./logs/calls.jsonl
# debug_payload_log: ./logs/payloads.jsonl  # OPT-IN, off by default: logs raw
#                                   # arguments AND results — can contain secrets
# Optional global call limits (each can be overridden per upstream):
# rate_limit: { rps: 5, burst: 2 }  # token bucket per upstream for tools/call
#                                   # (refusal → client error -32029, retryable)
# max_result_bytes: 65536           # truncate oversized textual results (0 = off;
#                                   # non-text over-limit results get a _meta marker)
# call_timeout: 30s                 # bounds one upstream request
# How the catalog is presented to the client (both hot-reloadable):
# catalog_mode: lazy                # normal (default) | lazy: the client sees only
#                                   # gate_search_tools / gate_describe / gate_call
# page_size: 50                     # paginate tools/list (0/omitted = whole catalog;
#                                   # ignored in lazy mode)
# Auto-restart policy for crashed stdio upstreams (defaults: on, 1s→30s, 5 tries):
# restart: { enabled: true, initial_backoff: 1s, max_backoff: 30s, max_attempts: 5 }
# env_isolation: strict             # opt-in ("" default = inherit the gateway's full
#                                   # env): a stdio upstream then gets only a minimal
#                                   # base (PATH/HOME/...) plus its own env: block —
#                                   # NOT other upstreams' secrets or the auth token
upstreams:
  - name: filesystem        # stdio upstream
    command: npx
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"]
    enabled: true
  - name: github
    command: github-mcp-server
    env:
      GITHUB_TOKEN: ${GITHUB_TOKEN}   # from the environment, not hardcoded
    enabled: true
    # Optional per-upstream tool filter / catalog projection (keys are ORIGINAL
    # tool names; all editable live via SIGHUP with no upstream restart):
    # tools:
    #   allow: ["search_repositories"]  # if non-empty, only these survive
    #   deny: ["delete_repository"]     # always subtracted, even from allow
    #   rename: { search_repositories: "gh_search" }
    #   strip_annotations: true         # drop heavyweight catalog fields
    #   strip_output_schema: true
    #   max_description: 200            # truncate descriptions to N runes
    #   describe: { get_issue: "Fetch one issue." }   # replace wholesale
    # Optional per-upstream call limits (override the globals for this upstream):
    # rate_limit: { rps: 1, burst: 1 }  # rps: 0 disables the global limit here
    #                                   # (refusal → client error -32029, retryable)
    # max_concurrent: 4                 # cap on simultaneous in-flight calls
    #                                   # (refusal → client error -32029, retryable)
    # max_result_bytes: 32768           # 0 disables the global cap here
    # call_timeout: 120s                # this upstream is slow — give it longer
  - name: remote            # http upstream (Phase 2)
    url: https://mcp.example.com/mcp
    headers:
      Authorization: "Bearer ${REMOTE_MCP_TOKEN}"   # secret, never logged
    enabled: true

Что видит клиент, когда срабатывает лимит вызовов

Два из лимитов вызовов выше проявляются у MCP-клиента (агента), а не только в журнале оператора:

  • Отказы защиты (rate_limit / max_concurrent). Когда шлюз отклоняет tools/call, потому что ограничитель скорости или потолок параллельности для данного вышестоящего сервера не смог его пропустить, клиент получает JSON-RPC-ошибку с собственным кодом шлюза -32029 и машиночитаемым data: {"retryable": true, "reason": "rate_limit" | "concurrency_limit"}. Вызов никогда не доходит до вышестоящего сервера, поэтому агент может подождать и повторить без риска двойного выполнения. Обычные транспортные/маршрутные сбои сохраняют исторический -32603, а ошибка, которую возвращает сам вышестоящий сервер, передаётся дословно, код и данные без изменений — -32029 от вышестоящего сервера не является сигналом шлюза.

  • Результаты, превышающие размер и не поддающиеся усечению (max_result_bytes). Текстовые результаты ужимаются с внутриконтентной меткой [truncated by mcp-gate: …]. Нетекстовый / нестандартный результат, превышающий лимит, но не имеющий усекаемого текста (например, только изображения), передаётся целиком и байт-в-байт — его content[] никогда не изменяется — но _meta результата получает ключ шлюза io.github.akomyagin.aimcpgate/result-over-limit с {"limitBytes": N, "resultBytes": M}, чтобы агент мог понять, что лимит был обойдён. Клиент, не знающий этого ключа, просто игнорирует его. Событие журнала оператора result_truncation_skipped по-прежнему срабатывает как раньше.

Лицензия

MIT — см. LICENSE.

Available Tools

2 tools
demo-echo__echoB

Echo the given text back verbatim.

ParametersJSON Schema
NameRequiredDescriptionDefault
textYesText to echo back verbatim.

TDQS

B3.4/5.0
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations exist, so the description bears full responsibility. It merely repeats the parameter description ('Echo the given text back verbatim') without adding behavioral details such as side effects, error handling, or output format.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single, efficient sentence with no extraneous information. Every word serves the purpose.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's simplicity (one required parameter, no output schema), the description is largely complete. It could explicitly mention the return format, but the behavior is trivially inferable.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, so the baseline is 3. The description adds no new meaning beyond the parameter's description, which already states 'Text to echo back verbatim.'

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Description clearly states the action ('Echo') and the resource ('the given text back verbatim'), making the purpose unmistakable. The sibling tool 'ping' likely serves a different function (connectivity test), so this description distinguishes well.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

No guidance is provided on when to use this tool versus the sibling 'ping' or any alternatives. The description simply states what it does without contextual usage advice.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

demo-echo__pingA

Health check: always returns "pong".

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

TDQS

A4.5/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations provided, but the description fully discloses the output ('always returns pong'), leaving no ambiguity about its behavior for a simple ping tool.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

One concise sentence that is front-loaded with purpose and output, no unnecessary words.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple health check tool with no parameters and no output schema, the description is complete and sufficient for an agent to use correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

No parameters in the schema; description adds no parameter info, but baseline for 0-param tools is 4.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states it is a health check that always returns 'pong', which is specific and distinguishes it from the sibling tool 'echo'.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies usage for health checks, and with only one sibling ('echo'), the purpose is clear without explicit alternatives. Slightly lacking explicit when-not-to-use guidance.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

TDQS

A4.1/5.0
Disambiguation5/5

The two tools serve completely different purposes: echo returns input text, ping returns a fixed health response. No ambiguity.

Naming Consistency5/5

Both tools follow the same pattern: prefix 'demo-echo__' followed by a verb. The naming is uniform and predictable.

Tool Count4/5

With only 2 tools, the set is minimal but appropriate for a simple demonstration server. It covers the core functionality without being overly sparse.

Completeness5/5

For the stated purpose of a demo echo server, the tools provide exactly what is needed: echo text and health check. No obvious gaps.

Maintenance

ActivityMaintained
ResponsivenessSyncing

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    Local-first MCP proxy with BM25 tool discovery, quarantine security, Docker isolation, OAuth support, activity logging, and web UI. Routes multiple upstream MCP servers through a single endpoint.
    9
    332
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP Gateway that aggregates multiple upstream MCP servers into a single endpoint with persistent connections, tool registry, and authentication.
    11
    2
    MIT
  • F
    license
    A
    quality
    D
    maintenance
    Universal MCP proxy server that discovers, searches, and executes tools across all configured MCP servers from a single entry point.
    7
  • A
    license
    A
    quality
    A
    maintenance
    A zero-dependency MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, and gate every call through your own policy hooks before it runs.
    2
    4
    25
    35
    MIT

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/akomyagin/aiMCPGate'

If you have feedback or need assistance with the MCP directory API, please join our Discord server