Skip to main content
Glama

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 de prompts/resources/ resources/templates/completion, ping, reenvío de progreso y cancelación real, logging/setLevel fan-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 de tools/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/createMessage y roots/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ó sesiones Mcp-Session-Id en el lado del servidor con terminación DELETE /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ó silenciosamente max_result_bytes — ahora llegan al diario de llamadas (mcp-gate logs) en lugar de a un stderr que 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: una tools/call rechazada por el límite de tasa o la guardia de concurrencia ahora devuelve su propio código de error JSON-RPC -32029 con data: {"retryable":true,"reason":...} legible por máquina en lugar de un -32603 indistinguible, y un resultado no textual que eludió max_result_bytes lleva un marcador result._meta (content permanece byte a byte sin tocar). Finalmente, auth_token que 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 una VAR no 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 elimina auth_token para 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 entrada ERR dispersa 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 /mcp después de initialize (el encabezado se devuelve en la respuesta de initialize), 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.yaml

npx (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.yaml

Polí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:

  1. El binario es /mcp-gate y NO está en $PATH. El Dockerfile hace COPY mcp-gate /mcp-gate y ENTRYPOINT ["/mcp-gate"] — nada lo pone en una ruta de búsqueda (revisa el Dockerfile si 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 $PATH

    Usa la ruta absoluta en su lugar — esa es la única diferencia.

  2. 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 que sh simplemente no está allí, y no hay ls/cat para mirar alrededor. Mantén tuberías, globbing y redirección en el lado del HOST del comando.

  3. docker exec inicia un NUEVO proceso; no consulta el serve en ejecución. doctor, catalog y call construyen 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 una call que hagas de esta manera no aparece en 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

Los 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:

  • logs es 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 que log_file en 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, a docker logs) y mcp-gate logs no tiene nada que leer. -c es lo que le dice dónde está el diario; --file lo 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 …) con transport: http.

  • El modo HTTP necesita un listen_addr no predeterminado. El predeterminado es 127.0.0.1:28080 — loopback DENTRO del contenedor, inalcanzable desde el host incluso con -p. Establece listen_addr: 0.0.0.0:<port> en la configuración; la puerta de enlace entonces se niega a iniciar sin un auth_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 version

Uso

# 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

Todos 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 -32603 y 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/call de 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

upstream_start_failed

Un upstream nunca se levantó; sus herramientas están ausentes del catálogo.

upstream_gave_up

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.

notification_dropped

El buffer de un suscriptor estaba lleno, así que se descartó una notificación reenviada — el reenvío es no bloqueante por diseño.

server_request_dropped

Un upstream pidió algo que solo el cliente podía responder (elicitation/sampling/roots) y ningún transporte tomó la pregunta, así que la llamada de herramienta fue rechazada en su nombre.

sse_stream_unavailable

Un upstream HTTP no ofrece un stream SSE GET, así que su tools/list_changed nunca llegará hasta que el gateway se reinicie.

catalog_collision

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.

catalog_bad_template

Una plantilla de URI de recurso no compila: se lista al cliente pero nunca puede coincidir con una lectura.

result_truncation_skipped

Un resultado excedió max_result_bytes pero no tenía texto truncable (p. ej. solo imágenes), así que pasó completo.

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í que mcp-gate logs de ≤ 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 space

Fingerprintea 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á tu config.yaml — pasa --config explícitamente cuando uses go 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_token que referencia una variable no definida hace fallar el arranque, nombrando la variable — un auth_token vací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 clave auth_token por completo.

  • Una variable no definida en env/headers de 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 evento unresolved_secret_var en el diario (mcp-gate logs) y una línea WARN en mcp-gate doctor.

  • En modo stdio, mcp-gate client-config advierte (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: true

Lo 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 una tools/call porque 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 -32029 y data legible 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 -32029 de 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 — su content[] nunca se altera — pero el _meta del resultado gana la clave de la puerta de enlace io.github.akomyagin.aimcpgate/result-over-limit con {"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 evento result_truncation_skipped del diario del operador sigue ocurriendo como antes.

Licencia

MIT — ver 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.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 2 tool updatesv0.3.0
    • First observeddemo-echo__echo
    • First observeddemo-echo__ping

TDQS

A4.1/5.0

Scored across 2 tools

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

ActivitySlowing
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • 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.
    31 npm
    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
    C
    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.
    4
    34 npm
    38
    MIT