aiMCPGate
aiMCPGate
Русская версия — README_RU.md.
Una puerta de enlace / proxy para servidores MCP (Model Context Protocol) escrito en Go. Se presenta ante un cliente MCP (Claude Code, Cursor, etc.) como un servidor MCP, mientras que internamente multiplexa las llamadas entre varios servidores MCP ascendentes, agrega sus herramientas, prompts y recursos en un único catálogo, y registra cada llamada.
Estado: MVP completo (Etapas 0–6) + etapas post-MVP 7–18 publicadas, última versión v0.5.0. Fase 1 — multiplexación de upstreams stdio detrás de un endpoint stdio con registro de llamadas; Fase 2 — transporte HTTP/SSE para el cliente, upstreams HTTP, un visor de registros CLI (
mcp-gate logs); pipeline de publicación (goreleaser, compilación cruzada para linux/darwin/windows × amd64/arm64, sin CGO). Post-MVP se añadieron reinicio automático de upstreams, recarga de configuración en caliente, filtrado/renombrado de herramientas,doctor, y — en v0.3.0 — agregación completa deprompts/resources/resources/templates/completion,ping, reenvío de progreso y cancelación real,logging/setLevelfan-out, límites de llamadas por upstream (límite de tasa / concurrencia / truncamiento de resultados / tiempo de espera), un catálogo perezoso y paginación detools/list, y flujos SSE de servidor a cliente tanto en el lado del cliente como en el lado del upstream. v0.4.0 completó la dirección servidor→cliente: los tres métodos iniciados por el servidor —elicitation/create,sampling/createMessageyroots/list— se proxifican en las cuatro combinaciones de transporte (stdio o HTTP en el lado del cliente × stdio o HTTP en el lado del upstream); la puerta de enlace ahora declara a un upstream exactamente las capacidades que su propio cliente declaró en lugar de un{}genérico; el transporte HTTP ganó sesionesMcp-Session-Iden el lado del servidor con terminaciónDELETE /mcp. v0.5.0 añade observabilidad para el operador (Etapa 18): ocho tipos de eventos — fallos de inicio de upstream y abandonos del supervisor, notificaciones descartadas y solicitudes servidor→cliente, un upstream HTTP sin GET SSE, colisiones de catálogo y plantillas URI malas, y un resultado que eludió silenciosamentemax_result_bytes— ahora llegan al diario de llamadas (mcp-gate logs) en lugar de a unstderrque un cliente MCP normalmente posee; el análisis de configuración se volvió estricto (claves desconocidas/ mal escritas son fatales). También cierra la mitad orientada al cliente de la historia de guardia/truncamiento: unatools/callrechazada por el límite de tasa o la guardia de concurrencia ahora devuelve su propio código de error JSON-RPC-32029condata: {"retryable":true,"reason":...}legible por máquina en lugar de un-32603indistinguible, y un resultado no textual que eludiómax_result_byteslleva un marcadorresult._meta(contentpermanece byte a byte sin tocar). Finalmente,auth_tokenque referencia una variable de entorno no definida ahora se niega a iniciar la puerta de enlace en lugar de deshabilitar silenciosamente la autenticación HTTP.Actualización a v0.5.0 — tres cambios de comportamiento, ninguno toca el formato del archivo de configuración en sí:
El análisis de configuración ahora es estricto. Una configuración con una clave desconocida o mal escrita a nivel superior o por upstream, que solía ignorarse silenciosamente, ahora falla al cargar. Corrige el nombre de la clave (el error la nombra) o elimínala.
auth_token: ${VAR}con unaVARno definida ahora se niega a iniciar, nombrando la variable. Antes, se convertía silenciosamente en un token vacío — lo que, en una puerta de enlace HTTP, deshabilitaba la verificación de portador por completo sin advertencia. Establece la variable (o pasa--env-file), o eliminaauth_tokenpara ejecutar sin autenticación a propósito.El diario de llamadas (
log_file/calls.jsonl) ganó un segundo tipo de registro,"kind":"event", junto a los registros de llamadas existentes. Un binario en v0.4.0 o anterior que lea un diario v0.5.0 renderiza una línea de evento como una entradaERRdispersa en lugar de fallar — lee un diario con el mismo binario o uno más nuevo que el que lo escribió.Actualización a v0.4.0: sin cambio en el archivo de configuración, pero dos cambios de comportamiento observables en modo HTTP — un id de sesión ahora es obligatorio en
POST /mcpdespués deinitialize(el encabezado se devuelve en la respuesta deinitialize), y el registro de upstreams se inicia perezosamente en la primera solicitud MCP real en lugar de al inicio del proceso.No implementado: una política de acceso por cliente.
Lanzamientos
Los binarios multiplataforma se compilan mediante goreleaser
(.goreleaser.yaml): linux/darwin/windows × amd64/arm64, sin CGO,
la versión se incorpora mediante -ldflags -X main.version=..., las sumas de verificación van en
SHA256SUMS. Prueba local en seco: goreleaser release --snapshot --clean.
Related MCP server: mcpstead
Instalar desde el registro MCP
Además de los binarios de lanzamiento sin procesar, la puerta de enlace se distribuye como una imagen OCI en GitHub Container Registry y como un paquete envoltorio npm — los dos formatos desde los que los registros MCP instalan.
Docker:
docker run --rm -i -v $(pwd)/config.yaml:/config.yaml ghcr.io/akomyagin/aimcpgate serve-i es obligatorio: la puerta de enlace habla MCP sobre stdio, por lo que el cliente debe mantener
stdin abierto (sin él, el contenedor ve EOF y sale inmediatamente). La
imagen no tiene configuración propia, así que monta la tuya — el ejemplo anterior la monta
en la ruta predeterminada /config.yaml; cualquier otra ruta funciona con serve -c.
Para reproducir una comprobación de sandbox de registro (Glama.ai, etc.) sin ningún upstream real, usa la configuración de demostración integrada en la imagen — este comando exacto es lo que un sandbox debería ejecutar:
docker run --rm -i ghcr.io/akomyagin/aimcpgate serve -c /demo.config.yamlnpx (descarga el binario precompilado para tu plataforma en la primera instalación y verifica su suma de verificación SHA256):
npx aimcpgate serve -c ./config.yamlPolítica de imagen: la imagen OCI contiene solo el binario mcp-gate — sin
tiempos de ejecución para upstreams stdio (sin node/npx, python, shells). Si tu configuración
lanza servidores upstream stdio, extiende la imagen tú mismo e instala lo que
necesiten; los upstreams HTTP funcionan de serie (los certificados CA están incluidos).
Configuración de demostración: demo.config.yaml y el subcomando oculto
__demo-echo existen solo para que los sandboxes de registro (Glama.ai) puedan
inspeccionar la puerta de enlace sin ningún upstream real — nunca los uses en una implementación
real.
Ejecutar comandos CLI dentro de un contenedor
doctor, catalog, call y logs son cómo un operador inspecciona una
implementación. Tres hechos deciden cómo deben invocarse dentro de un contenedor:
El binario es
/mcp-gatey NO está en$PATH. ElDockerfilehaceCOPY mcp-gate /mcp-gateyENTRYPOINT ["/mcp-gate"]— nada lo pone en una ruta de búsqueda (revisa elDockerfilesi esto alguna vez parece incorrecto). Entonces la forma obvia falla:$ 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 $PATHUsa la ruta absoluta en su lugar — esa es la única diferencia.
La imagen es distroless, por lo que no hay shell en absoluto. La base es
gcr.io/distroless/static-debian12:nonroot, que incluye el binario y los certificados CA y nada más.docker exec mcp-gate sh -c '…'falla de la misma manera queshsimplemente no está allí, y no hayls/catpara mirar alrededor. Mantén tuberías, globbing y redirección en el lado del HOST del comando.docker execinicia un NUEVO proceso; no consulta elserveen ejecución.doctor,catalogycallconstruyen su propio registro, abren sus propias conexiones a los upstreams, informan y salen. Su salida es por lo tanto la accesibilidad del upstream ahora mismo, no el estado de la puerta de enlace en vivo: si el proceso en ejecución perdió un upstream y lo eliminó de su catálogo, estos comandos no mostrarán eso. También mantienen limpio el diario de llamadas — se ejecutan con el registro deshabilitado, por lo que unacallque hagas de esta manera no aparece enlogs.
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 50Los comandos asumen un contenedor iniciado separado y con nombre, por ejemplo,
docker run -d --name mcp-gate … — a diferencia del ejemplo anterior en primer plano docker run --rm -i …, que sale tan pronto como su cliente stdio se desconecta y
no deja nada a lo que docker exec pueda llegar. Se asume que la configuración está montada en
la ruta predeterminada /config.yaml, como en ese mismo ejemplo; demo__echo representa
una herramienta de tu propio catálogo. Algunas advertencias:
logses la excepción al hecho 3: lee el ARCHIVO de diario que la puerta de enlace en ejecución escribe, por lo que sí refleja el proceso en vivo. Eso requiere quelog_fileen la configuración montada apunte a una ruta visible dentro del contenedor, y un volumen montado allí — de lo contrario, el diario va al stderr del contenedor (es decir, adocker logs) ymcp-gate logsno tiene nada que leer.-ces lo que le dice dónde está el diario;--filelo anula.Esto realmente se trata del modo HTTP. En modo stdio, el cliente MCP inicia y posee el contenedor, por lo que generalmente no hay un contenedor de larga duración al que hacer
exec. Una puerta de enlace que puedas inspeccionar es una iniciada por separado (docker run -d --name mcp-gate …) contransport: http.El modo HTTP necesita un
listen_addrno predeterminado. El predeterminado es127.0.0.1:28080— loopback DENTRO del contenedor, inalcanzable desde el host incluso con-p. Establecelisten_addr: 0.0.0.0:<port>en la configuración; la puerta de enlace entonces se niega a iniciar sin unauth_token, a propósito ("el endpoint HTTP sería alcanzable desde la red sin autenticación").
Por qué
Un usuario activo de MCP típicamente tiene varios servidores configurados (sistema de archivos,
GitHub, búsqueda, personalizados), cada uno duplicado en la configuración de cada cliente.
aiMCPGate te da:
Un punto de entrada — un único endpoint MCP en lugar de N entradas en la configuración del cliente.
Un catálogo — las herramientas y prompts de cada servidor upstream fusionados (con espacios de nombres
<upstream>__<tool>para que los nombres nunca colisionen), más sus recursos y plantillas de recursos (direccionados por URI, por lo que nunca se renombran).Un registro de llamadas — qué upstream, qué herramienta, cuándo, éxito/fracaso. Este es el valor añadido sobre "solo un proxy".
Proyecto personal en solitario: la prioridad es aprender Go (concurrencia, os/exec,
JSON-RPC 2.0, los transportes stdio y HTTP/SSE). Coste — $0/mes por
defecto (un proceso local), sin telemetría.
Cómo funciona (versión corta)
MCP client ──stdio/HTTP──▶ aiMCPGate ──JSON-RPC──▶ upstream A (stdio)
│ ├─────────▶ upstream B (stdio)
call log └─────────▶ upstream C (http, Phase 2)MVP (dos fases)
Fase 1 — multiplexar 2+ upstreams stdio detrás de un endpoint stdio (el mismo transporte que habla Claude Code) más registro básico.
Fase 2 — transporte HTTP/SSE, servidores upstream HTTP, un visor de registros (se construyó el CLI; la vista web se descartó deliberadamente), opcionalmente una política de acceso — esa se consideró y se rechazó.
Compilar
export PATH="$HOME/sdk/go/bin:$PATH" # if go isn't already on PATH
go build ./...
go vet ./...
go test -race ./...
go run ./cmd versionUso
# 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-gateTodos los comandos excepto token --generate, completion y skill (que cae
a una guía integrada) cargan la configuración: pasa --config, o coloca un
config.yaml junto al binario (ver Configuración abajo).
serve, doctor, call y catalog también aceptan --env-file ./.env — un
analizador mínimo de KEY=VALUE aplicado antes de que se cargue la configuración, por lo que las referencias ${VAR}
dentro de la configuración se resuelven desde ese archivo. El entorno real del proceso
siempre gana sobre el archivo.
Sesiones HTTP (Mcp-Session-Id)
En modo http, la puerta de enlace ejecuta sesiones HTTP Streamable: la respuesta a
initialize lleva un encabezado Mcp-Session-Id, y cada solicitud posterior
— POST, el flujo GET SSE, DELETE — debe enviar ese encabezado de vuelta. Sin él, la
respuesta es 400; con un id desconocido o caducado, 404, lo que le dice al
cliente que haga initialize de nuevo. Una sesión se libera mediante DELETE /mcp (204),
o después de 30 minutos sin solicitudes — un flujo SSE abierto cuenta como actividad y lo
mantiene vivo.
Los clientes MCP hacen todo esto por ti. Para llamadas curl hechas a mano, toma el encabezado
de la respuesta de initialize y devuélvelo:
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"La sesión también hace honesto el registro de llamadas: cada llamada se audita bajo el
clientInfo de la sesión que la hizo, por lo que varios clientes HTTP se distinguen
en calls.jsonl en lugar de compartir un campo client en blanco.
Solicitudes servidor→cliente sobre HTTP (elicitation, sampling, roots)
Cuando un upstream pide algo a mitad de una llamada — elicitation/create,
sampling/createMessage, roots/list — la pregunta se entrega como un evento
SSE en el stream GET /mcp de una sesión, y el cliente responde con un
POST ordinario que lleva una respuesta JSON-RPC con el mismo id y el mismo
Mcp-Session-Id. Solo la sesión a la que se le hizo la pregunta puede
responderla; una respuesta de cualquier otra sesión se ignora. Si nadie ha
declarado la capacidad con un stream abierto, el upstream es rechazado de
inmediato en la forma que prescribe la especificación ({"action":"decline"}
para elicitation, -32601 para los otros dos) en lugar de dejarlo expirar — y
lo mismo ocurre si la sesión se termina mientras hay una pregunta pendiente.
Tres consecuencias que vale la pena conocer:
A los upstreams se les informa de las capacidades del PRIMER cliente que inicializa, y ese conjunto queda fijo durante la vida del proceso. MCP 2025-06-18 no tiene renegociación, así que un segundo cliente que declare más no puede cambiar los handshakes que ya ocurrieron — a un upstream nunca se le promete una capacidad en nombre de un cliente del que no se le informó.
Los upstreams arrancan en la primera solicitud que los necesita, no cuando el gateway vincula su puerto. Eso es lo que hace posible la declaración anterior: el handshake tiene que ocurrir después de que un cliente haya dicho qué soporta. Si los upstreams no pueden arrancar, el cliente recibe un JSON-RPC
-32603y el gateway sale con el error, como hacía cuando los arrancaba de forma anticipada.La pregunta va a un cliente que declaró la capacidad — no necesariamente al que provocó la llamada. El enrutamiento es por capacidad declarada, y entre las sesiones que coinciden, la más recientemente activa gana; una solicitud de upstream no lleva nada que indique a qué llamador pertenece. Con un solo cliente (el caso normal) esto es invisible, pero ejecuta dos y un formulario levantado por el
tools/callde un cliente puede aparecer en la interfaz del otro.
El lado upstream del mismo intercambio también funciona por HTTP: un
servidor MCP remoto alcanzado con url: puede hacer su pregunta como un frame
SSE — ya sea en su stream GET de larga duración o intercalado en el stream
que responde a uno de los propios POST del gateway, que es donde los servidores
SDK ponen un elicitation/create levantado dentro de un tools/call. El
gateway lo proxifica a través del mismo pipeline y envía la respuesta del
cliente de vuelta como un POST ordinario que lleva una respuesta JSON-RPC
bajo el propio id de solicitud del servidor. A tal upstream se le informan
las capacidades de cliente del gateway con la misma política honesta que a uno
de stdio — una capacidad se ofrece solo cuando el propio cliente del gateway la
declaró, y doctor/call/catalog, que no tienen ningún cliente, siguen
declarando exactamente {}. El POST de respuesta no se reintenta: un upstream
que no lo recibe cae en su propio timeout.
Eventos de operador en el diario
El diario en log_file contiene dos tipos de línea: una por llamada de
herramienta, y una por evento de operador — un estado del gateway que de
otro modo nunca sabrías. En modo stdio, el cliente MCP es dueño de la terminal,
así que el stderr del gateway es invisible para ti, y varias de estas
condiciones solo se registraban a nivel de depuración. Ahora se escriben en el
mismo archivo que mcp-gate logs lee:
Evento | Qué significa |
| Un upstream nunca se levantó; sus herramientas están ausentes del catálogo. |
| El supervisor dejó de reiniciar un upstream (intentos agotados, reinicio deshabilitado por una recarga, o sin canal de liveness) y lo eliminó del catálogo. |
| El buffer de un suscriptor estaba lleno, así que se descartó una notificación reenviada — el reenvío es no bloqueante por diseño. |
| Un upstream pidió algo que solo el cliente podía responder ( |
| Un upstream HTTP no ofrece un stream SSE |
| Dos entradas reclamaron el mismo nombre de herramienta/prompt o URI de recurso orientado al cliente; ganó el primero y el perdedor queda oculto al cliente. |
| Una plantilla de URI de recurso no compila: se lista al cliente pero nunca puede coincidir con una lectura. |
| Un resultado excedió |
Los eventos aparecen en línea con las llamadas, marcados EVT; mcp-gate logs --events muestra
solo ellos, y --stats gana una tabla por evento. --tool y --status son
filtros solo de llamadas, así que los eventos se excluyen mientras cualquiera de los dos esté
establecido (--upstream aplica a ambos). Una consecuencia que vale la pena conocer: notification_dropped no nombra ningún
upstream — una caída es una propiedad del suscriptor cuyo buffer estaba lleno, no de
quien envió la notificación — así que --upstream X nunca lo muestra. Búscalo sin
ese filtro. Las caídas repetidas se fusionan — la primera se escribe de
inmediato, las siguientes dentro de un minuto se cuentan en el count= de la siguiente
línea para esa clave, y el resto se vacía al apagar. Una línea que lleva
tal acumulación lo dice en su detail=, nombrando el momento de la ocurrencia más antigua
que pliega — la propia marca de tiempo de la línea es la más reciente, así que los dos
juntos acotan cuándo ocurrió realmente la ráfaga.
Dos notas prácticas:
Establece
log_file. Con él vacío, el diario va a stderr, que en modo stdio pertenece al cliente MCP — los eventos se escribirían donde no puedes verlos.Lee un diario con el mismo binario (o uno más nuevo) que lo escribió. Los eventos llevan un campo
"kind"que las versiones anteriores no conocen, así quemcp-gate logsde ≤ v0.4.0 los renderiza como registros dispersos, mayormente vacíos.
Nada de esto es visible para el cliente MCP: no cambian códigos de error, cuerpos de resultado ni capacidades — los eventos van solo al diario.
Una llamada que el gateway no pudo enrutar no es un evento — es una línea CALL fallida ordinaria. Un cliente que pide un nombre de herramienta que ningún upstream proporciona
recibe un CallRecord como cualquier otro, con su columna upstream establecida al centinela
(unrouted); mcp-gate logs --upstream '(unrouted)' selecciona exactamente esas
líneas y nada más. Un segundo caso distinto se ve casi igual pero
nombra un upstream real en su lugar: la ruta existe (la herramienta está en el catálogo)
pero la conexión del upstream se ha ido (reiniciando o eliminado) — esa línea
lleva el nombre real del upstream, así que filtra con --upstream <name> como
de costumbre en lugar del centinela.
Recargando configuración (SIGHUP)
El gateway recarga su configuración en vivo con SIGHUP — sin reinicio, sin
conexión de cliente caída. Edita config.yaml y envía la señal:
kill -HUP $(pgrep -f 'mcp-gate serve')Al recargar, el gateway compara la nueva configuración con los upstreams en ejecución y
aplica el cambio mínimo: los upstreams recién agregados se lanzan, los eliminados (o
enabled: false) se apagan, los upstreams cuyos campos de lanzamiento
(command/args/url/env/headers) cambiaron se relanzan, y los upstreams
donde solo cambió el filtro de herramientas (allow/deny/rename, o las reglas
de proyección del catálogo strip_annotations/strip_output_schema/max_description/
describe) se re-proyectan
sin ningún reinicio. Los límites de llamadas (rate_limit, max_concurrent,
max_result_bytes, call_timeout — globales o por upstream) también se aplican
en vivo: nunca requieren un relanzamiento, la siguiente llamada simplemente usa los nuevos valores.
Los upstreams sin cambios siguen ejecutándose intactos. Una edición mala
(YAML inválido, validación fallida) se registra e ignora — la configuración
actualmente en ejecución sigue activa, así que un error tipográfico nunca derriba el gateway.
Nota de comportamiento: dado que el gateway instala un manejador de SIGHUP, SIGHUP ya no termina el proceso como lo haría el valor predeterminado del sistema operativo. Para detener el gateway usa Ctrl-C, SIGINT o SIGTERM.
SIGHUP es solo Unix. En Windows — o en cualquier lugar donde prefieras no enviar señales — usa la alternativa de sondeo opt-in en su lugar:
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 spaceFingerprintea el archivo de configuración en ese intervalo y aplica la misma ruta de recarga que toma SIGHUP. Ejecutarlo junto con el manejador de SIGHUP es seguro.
El observador compara el mtime y tamaño del archivo, y espera a que ese fingerprint se repita en el siguiente tick antes de leer el archivo. Eso es lo que hace que un guardado en dos pasos (truncar, luego llenar) sea seguro en la práctica: un escritor tiene que mantener el archivo en un estado medio escrito durante más de un intervalo completo de sondeo para engañar la verificación. El precio es latencia — una recarga aterriza dentro de hasta dos intervalos de sondeo (hasta 4s en el predeterminado de 2s).
En stdio, los upstreams se levantan en la primera solicitud del cliente, así que una edición hecha
antes de que cualquier cliente se haya conectado no puede aplicarse todavía. El observador mantiene esa
edición y la reintenta en cada sondeo hasta que el gateway esté arriba, luego la aplica — nunca
tienes que guardar el archivo una segunda vez para que surta efecto. Una edición rechazada de forma
definitiva (YAML no analizable, o el guardia de no-upstreams a continuación) se informa una vez y
no se reintenta.
Como red de seguridad en ambos disparadores, una recarga cuya nueva configuración no declara
upstreams en absoluto se rechaza y registra: esa es la firma de un
archivo medio escrito, y aplicarlo derribaría todos los upstreams en ejecución. Para
eliminar todos los upstreams deliberadamente, reinicia el gateway. Un
enabled: false explícito no se ve afectado — deshabilitar el último upstream aún se aplica.
Configuración
Sin --config, el gateway busca config.yaml junto a su propio binario
(p. ej. si mcp-gate está instalado en /etc/gate/, busca
/etc/gate/config.yaml — independientemente del directorio de trabajo desde el que se lanzó).
Si ese archivo no existe y --config tampoco se pasó, da un error
explícito en lugar de iniciar un gateway vacío. Las rutas relativas dentro
de la configuración (log_file, skill_file, debug_payload_log) se resuelven contra el
propio directorio del archivo de configuración, no el directorio de trabajo actual.
Las claves desconocidas son un error de inicio. La configuración se analiza estrictamente: una
clave mal escrita o no reconocida detiene el gateway con el nombre de la clave y su número
de línea, en lugar de ser ignorada silenciosamente como antes. La ganancia concreta: un
error tipográfico en enabled ya no puede dejar un upstream ejecutándose silenciosamente. Las claves
personalizadas x- de scratch también se rechazan — para compartir un bloque, pon un ancla YAML en el primer
upstream real y fúndelo (<<: *anchor) en los demás; los anclajes y las claves de fusión
funcionan como de costumbre.
Un upstream está habilitado por defecto: omite enabled: por completo y se
lanza como cualquier otro. Para mantener uno fuera del gateway sin eliminar su
configuración, deshabilítalo explícitamente con enabled: false — entonces aparece
ni en tools/list ni en la tabla de mcp-gate doctor. Cuidado: un
enabled: sin valor (o enabled: null) se lee como omitido, así que comentar el valor
deja el upstream ejecutándose — solo el literal false lo deshabilita.
Nota: la búsqueda "junto al binario" usa la ruta del ejecutable en ejecución. Bajo
go run ./cmd ...ese ejecutable es una compilación desechable en un directorio temporal, así que la búsqueda predeterminada no encontrará tuconfig.yaml— pasa--configexplícitamente cuando usesgo run, o ejecuta un binario compilado.
Ejemplo completo con todos los campos — config.example.yaml.
El conjunto de servidores ascendentes se declara en YAML; los secretos (tokens) van a través de
env/.env (expansión ${VAR} en el momento de la carga), nunca se confirman en la configuración.
Cada upstream define exactamente uno de command (subproceso stdio) o url
(servidor HTTP, Streamable HTTP) — el tipo de conexión se infiere automáticamente.
Las referencias ${VAR} sin definir se comportan de manera diferente según el campo:
auth_tokenque referencia una variable no definida hace fallar el arranque, nombrando la variable — unauth_tokenvacío deshabilitaría silenciosamente la verificación de portador HTTP, por lo que esto nunca se permite que ocurra en silencio. Para ejecutar sin autenticación, elimina la claveauth_tokenpor completo.Una variable no definida en
env/headersde un upstream no es un error: el valor se vuelve vacío y el secreto faltante aparece más tarde como un 401 de ese upstream. La puerta de enlace lo informa de antemano — un eventounresolved_secret_varen el diario (mcp-gate logs) y una líneaWARNenmcp-gate doctor.En modo stdio,
mcp-gate client-configadvierte (en stderr) que las variables de entorno del operador no se heredan por el cliente MCP, que lanza la puerta de enlace en su propio entorno — configúralas donde el cliente las ejecute.
Por defecto, cada upstream stdio hereda el entorno de proceso completo de la puerta de enlace —
incluyendo los secretos y auth_token que --env-file cargó
para otros upstreams. Establece env_isolation: strict (global) para que cada upstream
stdio reciba en su lugar solo una base mínima (PATH, HOME, TMPDIR y los
equivalentes de Windows) más su propio bloque env:, que siempre llega al
hijo. Recomendado cuando ejecutas upstreams de registros abiertos (paquetes lanzados con npx
que no confías completamente). Una variable de la puerta de enlace que sí quieres que un upstream
estricto vea, listala explícitamente en su env: (por ejemplo, PYTHONPATH: ${PYTHONPATH}).
El valor se lee en el momento del lanzamiento: un upstream en vivo mantiene el entorno con el que
comenzó hasta que se (re)inicia.
Por separado, y siempre activo: una vez que la puerta de enlace ha expandido un secreto ${VAR},
conoce el valor exacto, y redacta ese valor a *** en el texto libre orientado al operador —
el diario (mcp-gate logs) y los registros de fallos. Sin secretos configurados, esto no cuesta nada.
Un secreto que viaja sobre HTTP plano, no cifrado, a un host que no es de bucle local
recibe un WARN en el arranque y una línea en mcp-gate doctor (nunca un
fallo duro — texto claro en infraestructura confiable, por ejemplo, una VPN o una LAN aislada,
puede ser una elección deliberada que la puerta de enlace no puede cuestionar solo desde la configuración):
un auth_token no vacío vinculado más allá del bucle local, o los headers de un upstream
enviados a una url que no es https:// ni de bucle local. TLS en sí no está
integrado en la puerta de enlace — coloca un proxy inverso de terminación TLS al frente si lo
necesitas, el mismo patrón ya recomendado para TLS mutuo.
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: trueLo que el cliente ve cuando un límite de llamadas afecta
Dos de los límites de llamadas anteriores se muestran al cliente MCP (agente), no solo al diario del operador:
Rechazos de guardia (
rate_limit/max_concurrent). Cuando la puerta de enlace rechaza unatools/callporque el limitador de tasa por upstream o el tope de concurrencia no pudieron admitirla, el cliente recibe un error JSON-RPC con el código propio de la puerta de enlace-32029ydatalegible por máquina:data: {"retryable": true, "reason": "rate_limit" | "concurrency_limit"}. La llamada nunca llegó al upstream, por lo que un agente puede esperar y reintentar sin arriesgar una doble ejecución. Los fallos normales de transporte/enrutamiento mantienen el histórico-32603, y un error que un upstream mismo devuelve se reenvía textualmente, código y datos intactos — un-32029de un upstream no es una señal de la puerta de enlace.Resultados sobredimensionados que no se pueden truncar (
max_result_bytes). Los resultados de texto se reducen con un marcador en el contenido[truncated by mcp-gate: …]. Un resultado no textual / no estándar que excede el límite pero no tiene texto truncable (por ejemplo, solo imágenes) se pasa completo y byte por byte — sucontent[]nunca se altera — pero el_metadel resultado gana la clave de la puerta de enlaceio.github.akomyagin.aimcpgate/result-over-limitcon{"limitBytes": N, "resultBytes": M}para que un agente pueda saber que el límite fue eludido. Un cliente que no conoce la clave simplemente la ignora. El eventoresult_truncation_skippeddel diario del operador sigue ocurriendo como antes.
Licencia
MIT — ver LICENSE.
Available Tools
2 toolsdemo-echo__echoB
Echo the given text back verbatim.
| Name | Required | Description | Default |
|---|---|---|---|
| text | Yes | Text to echo back verbatim. |
TDQS
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.
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.
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.
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.
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.
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".
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
TDQS
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.
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.
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.
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.
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.
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.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
2 tool updates
v0.3.0- First observed
demo-echo__echo - First observed
demo-echo__ping
TDQS
Scored across 2 tools
The two tools serve completely different purposes: echo returns input text, ping returns a fixed health response. No ambiguity.
Both tools follow the same pattern: prefix 'demo-echo__' followed by a verb. The naming is uniform and predictable.
With only 2 tools, the set is minimal but appropriate for a simple demonstration server. It covers the core functionality without being overly sparse.
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
Related MCP Connectors
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
MCP Gateway: wrap any MCP server with cold-start retries, uptime SLA, and per-execution MPP billing.
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
MCP gateway to A2A discovery and free tool recommendations. Self-service enrollment; no wallet.
1
Related MCP Servers
- AlicenseAqualityAmaintenanceLocal-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.9388MIT
- AlicenseNot gradedqualityDmaintenanceMCP Gateway that aggregates multiple upstream MCP servers into a single endpoint with persistent connections, tool registry, and authentication.31 npm2MIT
- FlicenseAqualityDmaintenanceUniversal MCP proxy server that discovers, searches, and executes tools across all configured MCP servers from a single entry point.7-
- AlicenseAqualityCmaintenanceA 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.434 npm38MIT