Despliegue remoto
Modo servidor HTTP documenta el transporte opción por opción. Esta página es la otra mitad: cómo levantarlo para otras personas, en una máquina que se queda encendida, accesible desde algún sitio que no sea el portátil de quien lo administra.
Todo aquí asume modo HTTP. No existe una forma remota de stdio: stdio es una tubería entre un proceso cliente y un proceso servidor en la misma máquina, y en cuanto una segunda persona necesita llegar hasta él la respuesta es HTTP.
Elige primero la forma
Sección titulada «Elige primero la forma»| Situación | Forma |
|---|---|
| Un host, proxy y servidor juntos | Socket unix: --http-addr=/run/gitlab-mcp/server.sock |
| Proxy en otra máquina | --tls-cert y --tls-key en el propio listener |
| Sin ningún proxy | --tls-cert y --tls-key más --auth-mode=oauth; lee Exposición directa |
| Contenedores | La imagen publicada, sistema de ficheros raíz de solo lectura, digest fijado |
| Más de una instancia | Un balanceador con afinidad por token: Varias instancias |
Ejecutarlo como servicio
Sección titulada «Ejecutarlo como servicio»Dos formas, y lo que las separa es si un proxy en la misma máquina tiene que llegar a un socket unix. TCP en loopback con usuario dinámico es la forma con menos privilegios:
[Unit]Description=GitLab MCP ServerDocumentation=https://docs.jmrp.io/gitlab-mcp-server/operations/http-server/After=network-online.targetWants=network-online.target
[Service]Type=execDynamicUser=yesEnvironmentFile=/etc/gitlab-mcp-server/envExecStart=/usr/local/bin/gitlab-mcp-server \ --http \ --http-addr=127.0.0.1:8080 \ --gitlab-url=https://gitlab.example.com \ --auth-mode=oauth \ --public-url=https://mcp.example.com \ --trusted-proxy-header=X-Real-IP \ --trusted-proxies=127.0.0.1Restart=on-failureRestartSec=2sStandardInput=null
NoNewPrivileges=yesProtectSystem=strictProtectHome=yesProtectKernelTunables=yesProtectKernelModules=yesProtectControlGroups=yesPrivateTmp=yesPrivateDevices=yesRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6RestrictNamespaces=yesRestrictSUIDSGID=yesLockPersonality=yesMemoryDenyWriteExecute=yesSystemCallArchitectures=nativeSystemCallFilter=@system-serviceCapabilityBoundingSet=AmbientCapabilities=MemoryMax=512M
[Install]WantedBy=multi-user.targetProtectSystem=strict monta todo el sistema de ficheros en solo lectura y al
servidor le da igual: no escribe nada en disco. Los registros van a la salida de
error estándar, que recoge journald, y el único fichero que llega a crear es
el socket unix de la segunda forma, más abajo. Las lecturas no se ven
afectadas, así que los certificados y el fichero de entorno siguen siendo
legibles.
RestrictAddressFamilies= omite AF_NETLINK a propósito. El binario publicado
se compila con CGO_ENABLED=0 y usa el resolvedor propio de Go, que llega al
DNS por UDP y TCP y no pregunta nada al núcleo por netlink. Verificado contra
esta misma unidad: una comprobación de credencial que tuvo que resolver y llegar
a gitlab.com fue respondida por GitLab con y sin AF_NETLINK en la lista.
Un socket unix necesita un usuario fijo. DynamicUser=yes asigna un usuario
y un grupo transitorios en cada arranque, así que no hay ningún grupo al que
añadir el proxy. Crea un usuario de sistema y comparte un grupo real:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin gitlab-mcpsudo usermod -aG gitlab-mcp www-data # o nginx, o caddyDespués sustituye las líneas de identidad y de dirección:
User=gitlab-mcpGroup=gitlab-mcpRuntimeDirectory=gitlab-mcpRuntimeDirectoryMode=0750ExecStart=/usr/local/bin/gitlab-mcp-server \ --http \ --http-addr=/run/gitlab-mcp/server.sock \ --http-socket-mode=0660 \ --gitlab-url=https://gitlab.example.comRuntimeDirectory= hace que /run/gitlab-mcp exista, pertenezca al servicio y
se elimine al parar. Además tiene que poder escribirse: el servidor no
vincula la ruta publicada directamente. Crea un directorio de preparación 0700
al lado, vincula el socket allí y le aplica los permisos, y después lo enlaza a
su sitio, de modo que un socket solo aparece ya con sus permisos definitivos.
Un directorio padre de solo lectura, por tanto, falla, aunque el proceso no
escriba nada más.
Mantén /etc/gitlab-mcp-server/env con modo 0600 y propiedad de root:
systemd lo lee antes de soltar privilegios, así que el usuario del servicio
nunca necesita leerlo. Después instala y comprueba:
sudo systemctl daemon-reloadsudo systemctl enable --now gitlab-mcp-servercurl -fsS http://127.0.0.1:8080/health# La forma con socket: curl -fsS --unix-socket /run/gitlab-mcp/server.sock http://localhost/healthjournalctl -u gitlab-mcp-server -fmacOS, como demonio del sistema bajo /Library/LaunchDaemons/:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>Label</key> <string>io.github.jmrplens.gitlab-mcp-server</string> <key>ProgramArguments</key> <array> <string>/usr/local/bin/gitlab-mcp-server</string> <string>--http</string> <string>--http-addr=127.0.0.1:8080</string> <string>--gitlab-url=https://gitlab.example.com</string> </array> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <dict> <key>SuccessfulExit</key> <false/> </dict> <key>UserName</key> <string>_gitlabmcp</string> <key>StandardOutPath</key> <string>/usr/local/var/log/gitlab-mcp-server.log</string> <key>StandardErrorPath</key> <string>/usr/local/var/log/gitlab-mcp-server.log</string> <key>EnvironmentVariables</key> <dict> <key>GITLAB_MCP_LOG_LEVEL</key> <string>info</string> </dict></dict></plist>sudo launchctl load -w /Library/LaunchDaemons/io.github.jmrplens.gitlab-mcp-server.plistsudo launchctl print system/io.github.jmrplens.gitlab-mcp-serverEnvironmentVariables sirve para un ajuste como el nivel de registro. Pero un
plist es legible por cualquiera, así que un secreto no va ahí. Ponlo en un
fichero que lea el demonio, nombrado por GITLAB_MCP_ENV_FILE con una ruta
absoluta.
sc.exe create por sí solo no funciona. El Administrador de control de
servicios espera que el ejecutable que arranca registre un manejador de control
de servicio y le responda, y un binario de consola normal no lo hace, así que el
arranque falla con el error 1053, «no respondió a la petición de inicio a
tiempo». Un envoltorio de servicio cubre esa distancia:
nssm install GitLabMcpServer "C:\Program Files\gitlab-mcp-server\gitlab-mcp-server.exe"nssm set GitLabMcpServer AppParameters "--http --http-addr=127.0.0.1:8080 --gitlab-url=https://gitlab.example.com"nssm set GitLabMcpServer AppStdout "C:\ProgramData\gitlab-mcp-server\out.log"nssm set GitLabMcpServer AppStderr "C:\ProgramData\gitlab-mcp-server\err.log"nssm set GitLabMcpServer Start SERVICE_AUTO_STARTnssm start GitLabMcpServerwinget install --id jmrplens.gitlab-mcp-server -e instala un paquete portable
de ámbito de usuario bajo %LOCALAPPDATA%\Microsoft\WinGet\Packages\,
accesible mediante un enlace simbólico en un directorio Links por usuario. Un
servicio no se ejecuta como ese usuario y no debe depender de esa ruta, así que
añade --scope machine, que instala bajo %PROGRAMFILES%\WinGet\Packages\, o
copia el ejecutable a un directorio de máquina, y apunta el envoltorio al
fichero real y no al enlace.
--http-socket-mode se acepta en Windows y no se aplica: el servidor registra
un aviso y deja que decida la ACL del directorio. Usa allí un listener TCP.
Tres cosas en las que todo gestor de servicios se equivoca
Sección titulada «Tres cosas en las que todo gestor de servicios se equivoca»- Declara el transporte.
--transport autolee el descriptor de fichero 0 y elige HTTP solo cuando la entrada estándar es/dev/null. ElStandardInput=nullpor defecto de systemd da la misma respuesta por casualidad, pero una unidad que pongaStandardInput=socketottyobtiene stdio.--httpdice lo que quieres decir y no lo mueve cómo el supervisor conecte un descriptor de fichero. - La activación por socket no está soportada. Una unidad
.socketpasa su socket medianteLISTEN_FDS, a partir del descriptor de fichero 3, y la claveSocketsde launchd mediantelaunch_activate_socket(); el servidor no lee ninguno de los dos, así que un socket pasado así se ignora y el servidor vincula--http-addrpor su cuenta. SoloStandardInput=socket, oinetdCompatibilityde launchd, pone un socket en el descriptor de fichero 0, y este servidor lee un socket ahí como «un supervisor me ha dado un canal stdio», así que con--transport autohablaría JSON-RPC por él en lugar de aceptar conexiones. Vincula el socket tú mismo con--http-addr, y en systemd conRuntimeDirectory=. - Los secretos van en un fichero de entorno, nunca en la línea de órdenes. La
línea de órdenes de un servicio la puede leer cualquier usuario local: el
ExecStart=de una unidad de systemd, mediantesystemctl showy/proc, así que un secreto va enEnvironmentFile=. El modo HTTP no tiene ningún token de GitLab que esconder, pero existen dos secretos reales:GITLAB_MCP_TELEMETRY_IDENTITY_KEY, que no tiene opción de línea de órdenes precisamente por esto, y lo que lleveOTEL_EXPORTER_OTLP_HEADERS.
Un socket huérfano no siempre se limpia solo. Al arrancar, el servidor sondea un socket existente y lo elimina únicamente cuando la conexión es rechazada, lo que demuestra que nadie está escuchando. Un socket vivo se rechaza en lugar de robarse, una ruta que no es un socket nunca se borra, y un sondeo que falla por cualquier otro motivo (un permiso denegado, por ejemplo) se niega a arrancar y te dice que elimines el fichero a mano.
Ejecutarlo con Docker
Sección titulada «Ejecutarlo con Docker»La imagen y su línea de órdenes están descritas en Docker. Lo que sigue es la forma de despliegue.
Un contenedor
Sección titulada «Un contenedor»image=$(curl -fsSL https://raw.githubusercontent.com/jmrplens/gitlab-mcp-server/main/server.json \ | jq -r '.packages[] | select(.registryType == "oci" and (.identifier | startswith("ghcr.io/"))) | .identifier')docker run -d --name gitlab-mcp \ --restart unless-stopped \ -p 127.0.0.1:8080:8080 \ --read-only \ --tmpfs /tmp:rw,size=64m,mode=1777 \ --cap-drop ALL \ --security-opt no-new-privileges:true \ --memory 512m \ -e GITLAB_URL=https://gitlab.example.com \ "$image"La instancia llega como variable de entorno, no como argumento. Cualquier
argumento después del nombre de la imagen reemplaza el CMD por completo, y el
CMD es --transport auto --http-addr 0.0.0.0:8080. Escribir
docker run <imagen> --gitlab-url=… los perdería ambos en silencio, y sin
ningún transporte nombrado el proceso sirve stdio en lugar de HTTP.
-e GITLAB_URL= rellena la lista de instancias sin tocar la orden, porque el
modo HTTP lee la variable cuando falta la opción.
Sin -i. auto sirve HTTP exactamente cuando la entrada estándar es
/dev/null, que es lo que da docker run sin -i. Añadir -i le entrega al
contenedor una tubería, lo que significa un cliente, y el contenedor le habla
stdio a nadie.
-p 127.0.0.1:8080:8080 publica el puerto solo en loopback.
-p 8080:8080 lo publica en todas las interfaces y, con la integración de
iptables que Docker trae por defecto, eso se salta el cortafuegos de la máquina.
--read-only funciona porque la imagen no escribe nada en ejecución. La
única excepción es un socket unix, cuya vinculación necesita un directorio padre
con permiso de escritura para el paso de preparación descrito arriba, así que
dale un --tmpfs o un volumen. El contenedor ya se ejecuta como appuser (uid
10001), así que --user sobra, y --cap-drop ALL es seguro porque nada
escucha en un puerto privilegiado.
Fija el digest, no solo la etiqueta. Una etiqueta se puede volver a subir; un
digest es lo que un cliente resuelve de verdad. La primera orden lee la imagen que
fija para la release actual el server.json del MCP Registry: la etiqueta
3.2.0 con el digest que el workflow de release subió bajo ella, anotado en
el repositorio cuando se publicó la release y no leído ahora de la etiqueta.
Docker Hub guarda el mismo índice con el mismo digest. Resuélvelo una vez y
guarda el valor: un script que lo vuelve a leer en cada arranque pasa a la
siguiente release en cuanto se publica.
Compose
Sección titulada «Compose»services: gitlab-mcp-server: image: ghcr.io/jmrplens/gitlab-mcp-server:3.2.0 restart: unless-stopped networks: [mcp] ports: - "127.0.0.1:8080:8080" command: - "--http" - "--http-addr=0.0.0.0:8080" - "--gitlab-url=https://gitlab.example.com" - "--auth-mode=oauth" - "--public-url=https://mcp.example.com" - "--trusted-proxy-header=X-Real-IP" - "--trusted-proxies=172.28.0.1" read_only: true tmpfs: - /tmp:rw,size=64m,mode=1777 cap_drop: [ALL] security_opt: ["no-new-privileges:true"] deploy: resources: limits: { memory: 512M, cpus: "2.0" } healthcheck: test: ["CMD", "gitlab-mcp-server", "--probe"] interval: 30s timeout: 5s retries: 3 start_period: 10s logging: driver: json-file options: { max-size: "10m", max-file: "3" }
networks: mcp: ipam: config: - subnet: 172.28.0.0/24Una lista command: reemplaza el CMD, y por eso --http y --http-addr se
vuelven a escribir. Nombrar una instancia no es opcional: el modo HTTP termina
con --gitlab-url is required in HTTP mode cuando no se da ninguna, porque un
despliegue que no nombra ninguna instancia enviaría el token que aporte quien
llama al host que esa misma persona ponga en GITLAB-URL.
--trusted-proxies nombra desde dónde conecta el proxy, y tiene que ser una
dirección que solo el proxy pueda tener. El ejemplo declara su propia red con
una subred fija y confía únicamente en 172.28.0.1: la puerta de enlace, que
es desde donde llega, a través del puerto publicado, un proxy que corre en la
máquina. Un proxy que sea a su vez un contenedor de esa red recibe una
ipv4_address fija, y ese es el valor en el que confiar entonces. Los rangos
por defecto de Docker (172.16.0.0/12) son la respuesta equivocada: todos los
contenedores de la máquina viven en ellos, y cualquiera podría entonces decidir
a qué dirección se cargan los fallos de quien llama.
Varias opciones no tienen equivalente en variable de entorno y deben ir como
argumentos: --http-addr, --http-socket-mode, --tls-cert, --tls-key,
--trusted-proxy-header, --trusted-proxies, --stateless, --json-response,
--http-idle-timeout, --max-request-body-bytes, --allow-any-gitlab-url,
--resource-documentation, --resource-policy-uri y --resource-tos-uri, y
tampoco lo tienen los propios selectores de transporte --http y --transport.
Casi todas las demás pueden venir del entorno, --rate-limit-rps y
--rate-limit-burst incluidas, y una opción pasada explícitamente sigue
ganando.
El HEALTHCHECK de la propia imagen, desde la 3.0.0, es
gitlab-mcp-server --probe: el binario localiza el proceso del servidor dentro
del contenedor, lee --http-addr, --tls-cert y el transporte de la línea de
comandos de ese proceso, y pregunta por /health allí donde realmente se sirve.
Mueve el listener a otro puerto, a un socket unix o detrás de --tls-cert, y la
comprobación lo sigue sin que nadie se lo diga. Un contenedor que un cliente
ejecuta por stdio (docker run -i) no tiene listener; la sonda lo da por
saludable mientras el proceso corra. --probe <url>, --probe unix:<ruta> o
--probe host:puerto se salta el descubrimiento, para una comprobación lanzada
desde fuera del contenedor.
El bloque de Compose de arriba vuelve a declarar la comprobación de la imagen
solo para que un cambio en el intervalo de la imagen no cambie en silencio el
del despliegue; quita el bloque healthcheck: para usar el de la propia imagen.
Una imagen hasta la 2.7.5 no tiene --probe: su comprobación ejecuta wget
contra http://localhost:8080/health, así que cualquier otro listener queda
marcado como no saludable mientras sirve, y la línea test: de arriba falla ahí
como opción desconocida. Fijada a una de esas, vuelve a declarar la comprobación
con wget contra donde escuche ese despliegue.
Secretos
Sección titulada «Secretos»El modo HTTP no tiene ningún token de GitLab que inyectar. Lo que queda son
credenciales de telemetría, y esas no van en environment:, que docker inspect imprime entero. Monta el fichero y nómbralo:
secrets: - source: mcp_env target: /run/secrets/mcp.env environment: GITLAB_MCP_ENV_FILE: /run/secrets/mcp.env
secrets: mcp_env: file: ./secrets/mcp.envGITLAB_MCP_ENV_FILE se resuelve una sola vez desde el entorno del proceso
antes de cargar ningún fichero dotenv, así que un fichero cargado no puede
nombrar otro. Da una ruta absoluta: una relativa se resuelve contra el
directorio de trabajo y genera un aviso al arrancar. Se lleva bien con
read_only: true, porque el secreto llega como un montaje de solo lectura.
Una instancia o varias
Sección titulada «Una instancia o varias»--gitlab-url puede darse más de una vez, o una sola vez separada por comas.
Una instancia fija el despliegue y la cabecera GITLAB-URL se ignora. Varias
publican una lista permitida: la cabecera pasa a ser obligatoria, y una que
nombre algo fuera de la lista se rechaza en lugar de atenderse.
--allow-any-gitlab-url no publica ninguna y deja que la cabecera nombre
cualquier host, y una petición que entonces la omita se rechaza con 400; el
servidor se niega a arrancar con ella salvo que --http-addr escuche en una
dirección de loopback o en un socket unix, así que solo tiene sitio en un
despliegue local de un único usuario. La tabla completa está en
Publicar más de una instancia.
Tras un proxy inverso
Sección titulada «Tras un proxy inverso»Lo que todo proxy tiene que acertar
Sección titulada «Lo que todo proxy tiene que acertar»| Requisito | Por qué |
|---|---|
Declarar el host con --public-url | El proxy reenvía el Host del cliente, y un host que nadie declaró se rechaza con 403 por considerarse un intento de DNS rebinding. --public-url es lo que lo declara. La alternativa es listar la dirección del propio proxy en --trusted-proxies, junto con la --trusted-proxy-header que exige: un salto por el que el operador ha respondido puede reenviar cualquier host, que es lo que hace un despliegue que atiende varios nombres. El modo OAuth sigue exigiendo --public-url |
| Sin búfer de respuesta | El HTTP transmisible responde con text/event-stream. Un proxy con búfer convierte un stream vivo en una única entrega al final |
| Timeout de lectura largo | Un stream subscriptions/listen está en silencio entre notificaciones. El keep-alive SSE del servidor sale cada 25 segundos, por debajo del valor por defecto de 60 de nginx, pero un stream tranquilo quiere más margen |
| HTTP/1.1 hacia el upstream | HTTP/1.0 hacia el upstream no tiene transferencia troceada, que es lo que usa un cuerpo SSE |
Reenviar Authorization | El modo OAuth lee de ahí el token bearer; quitarla convierte cada petición en un 401 |
Reenviar PRIVATE-TOKEN | La cabecera del modo legacy |
Reenviar GITLAB-URL | Selecciona la instancia cuando se publican varias |
| Dirección real del cliente | --trusted-proxy-header nombra la cabecera que pone el proxy y --trusted-proxies las direcciones desde las que conecta, para que el limitador de fallos de autenticación cuente clientes y no al proxy |
Enrutar /.well-known/ sin cambios | En modo OAuth los metadatos RFC 9728 viven en la raíz del host, no bajo el prefijo de ruta |
| No añadir cabeceras CORS | El servidor responde el preflight por sí mismo. Dos cabeceras Access-Control-Allow-Origin son un fallo de CORS, no una fusión |
--public-url es lo que hace aceptable el host reenviado. Todos los proxies
de abajo conservan el Host del cliente y conectan con el servidor por
loopback, que es también la forma de un ataque de DNS rebinding: una página
resuelve un nombre que controla el atacante a la dirección de loopback, y el
navegador llega al servidor local con ese nombre en Host. Así que un
despliegue al que se llega como mcp.example.com arranca con
--public-url=https://mcp.example.com, y cualquier otro host se sigue
rechazando con 403, en /health igual que en /mcp. Una petición que no
lleva ningún Host, que es lo que envía la comprobación de salud de un
balanceador, se sirve. La regla completa, la alternativa del proxy de confianza
y lo que hace un listener comodín están en
Tras un proxy inverso.
Dos más que muerden. No dejes que el proxy hable CORS: el servidor responde
su propio preflight a partir de --trusted-origins, a la que se añade
automáticamente el origen de --public-url, y un proxy que también emita
Access-Control-Allow-Origin produce dos, que los navegadores rechazan de
plano mientras curl informa de un alegre 200, así que solo falla en el único
cliente que importa. test/e2e/http fija esa colisión con un nginx real, y la
parte del servidor está en
Protección cross-origin.
La única excepción es un documento que el proxy sirve por sí mismo en lugar de
reenviarlo: el AI Catalog del ejemplo de nginx nunca llega al servidor, así que
el proxy es lo único que puede enviar su cabecera CORS. Y todo lo que el proxy
no enrute es un 404, no un 401, porque el endpoint MCP se monta en
patrones concretos y no como comodín: una ruta que el servidor no sirve responde
{"error":"not found"} sin pedir ninguna credencial. Un 404 donde esperabas
un 401 suele significar que la ruta nunca llegó al manejador.
upstream gitlab_mcp { server 127.0.0.1:8080; keepalive 16;}
server { listen 443 ssl; http2 on; server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
# Metadatos RFC 9728: la ruta exacta que deriva --public-url, en la raíz # del host, nunca un prefijo y nunca reescrita. Este despliegue es dueño # de su nombre de host, así que deriva la ruta escueta. location = /.well-known/oauth-protected-resource { proxy_pass http://gitlab_mcp; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Connection ""; }
# AI Catalog: documento propio del host, servido aquí y nunca reenviado. # `types { }` vacía el mapa de extensiones, sin lo cual mime.types lo # envía como application/json y default_type nunca llega a aplicarse. location = /.well-known/ai-catalog.json { alias /etc/nginx/ai-catalog.json; types { } default_type application/ai-catalog+json; add_header Access-Control-Allow-Origin "*" always; add_header Cache-Control "public, max-age=3600" always; }
location / { proxy_pass http://gitlab_mcp; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Con keepalive arriba: vacía Connection para reutilizar las conexiones. proxy_set_header Connection "";
# Los eventos enviados por el servidor no deben retenerse. proxy_buffering off; proxy_request_buffering off; proxy_read_timeout 1h; proxy_send_timeout 1h; }}Acompáñalo de --trusted-proxy-header=X-Real-IP --trusted-proxies=127.0.0.1.
La lista es lo que hace que merezca la pena leer la cabecera: solo se cree en
una conexión que venga de una de esas direcciones, y una petición desde
cualquier otro sitio se carga a su propia dirección de origen, lleve la cabecera
que lleve. Sin la lista, quien llegara al listener directamente escribiría la
cabecera por su cuenta y elegiría la dirección a la que se cargan sus fallos,
que es por lo que el servidor se niega a arrancar con una opción y no la otra.
Cualquiera de las dos cabeceras sirve. Con X-Forwarded-For el servidor recorre
el valor desde la derecha, saltando cada salto que esté a su vez en la lista, y
carga el primero que no lo esté: con un nginx delante ese es el cliente, y con
un segundo proxy delante de nginx sigue siendo el cliente, siempre que los dos
proxies estén en la lista. Un valor que el cliente se inventara por la izquierda
nunca se alcanza, y un salto que no sea una dirección carga al origen de la
conexión. Una cabecera que llega en varias líneas de campo se lee como una sola
lista, con las líneas unidas en orden: nginx alarga la línea del cliente,
mientras que option forwardfor de HAProxy añade una línea propia detrás de
ella, y en ambos casos la dirección que oyó el proxy queda a la derecha.
X-Real-IP puesta desde $remote_addr en el salto más cercano al
servidor lleva una sola dirección y no necesita recorrido, y es la cabecera que
usa la propia prueba de extremo a extremo del proyecto con proxy.
nginx pasa las cabeceras de petición del cliente al upstream por defecto, así
que Authorization, PRIVATE-TOKEN y GITLAB-URL llegan sin ninguna línea
proxy_set_header. La única costumbre de nginx que conviene conocer es que
descarta las cabeceras que contienen guiones bajos salvo que se ponga
underscores_in_headers on; ninguna de las cabeceras de este servidor contiene
uno. Host es la excepción a ese paso directo: nginx envía el nombre de su
upstream salvo que se le diga otra cosa, y por eso las dos ubicaciones que
reenvían lo fijan. Vacían Connection por un motivo parecido: nginx envía
Connection: close al upstream salvo que se le diga otra cosa, lo que cerraría
cada conexión que keepalive está ahí para reutilizar.
# Caddyfilemcp.example.com { reverse_proxy 127.0.0.1:8080 { flush_interval -1 transport http { read_timeout 1h } }}Caddy obtiene y renueva el certificado por su cuenta y reenvía las cabeceras de
petición sin tocarlas, así que casi toda la lista de arriba no necesita
configuración. flush_interval -1 desactiva el búfer de respuesta de forma
explícita: Caddy ya vacía de inmediato para text/event-stream, y -1 hace que
el comportamiento deje de depender de que el upstream acierte con su tipo de
contenido.
Caddy pone X-Forwarded-For por defecto, así que
--trusted-proxy-header=X-Forwarded-For --trusted-proxies=127.0.0.1 encaja
aquí. Un único bloque de sitio cubre todo el host, de modo que la ruta
well-known ya queda enrutada.
Con el proveedor Docker, sobre el contenedor del bloque de Compose de arriba:
labels: - "traefik.enable=true" - "traefik.http.routers.mcp.rule=Host(`mcp.example.com`)" - "traefik.http.routers.mcp.entrypoints=websecure" - "traefik.http.routers.mcp.tls.certresolver=le" - "traefik.http.services.mcp.loadbalancer.server.port=8080"Traefik no hace búfer de las respuestas salvo que se añada un middleware
buffering, así que aquí el arreglo es no añadirlo. Lo que sí requiere atención
es el timeout de respuesta del entry point, que es global y no por ruta:
# configuración estática de traefikentryPoints: websecure: address: ":443" transport: respondingTimeouts: readTimeout: 0 idleTimeout: 3mreadTimeout: 0 elimina el límite de cuánto puede durar una petición, que es lo
que necesita un stream de larga duración. Traefik pone X-Forwarded-For cuando
es el borde, o cuando el salto anterior está en forwardedHeaders.trustedIPs.
Una regla Host() casa con todas las rutas del host, así que el documento
well-known queda cubierto. Una regla PathPrefix() es donde se abre la trampa:
un servidor enrutado con PathPrefix(`/gitlab`) y arrancado con
--public-url=https://mcp.example.com/gitlab publica sus metadatos en
/.well-known/oauth-protected-resource/gitlab, fuera de ese prefijo. Añade un
segundo router para exactamente esa ruta,
Path(`/.well-known/oauth-protected-resource/gitlab`), apuntando al mismo
servicio.
<VirtualHost *:443> ServerName mcp.example.com
SSLEngine on SSLCertificateFile /etc/letsencrypt/live/mcp.example.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/mcp.example.com/privkey.pem
ProxyPreserveHost On ProxyPass / http://127.0.0.1:8080/ flushpackets=on timeout=3600 connectiontimeout=10 ProxyPassReverse / http://127.0.0.1:8080/
RequestHeader set X-Forwarded-Proto "https"</VirtualHost>flushpackets=on es la que importa: sin ella mod_proxy_http hace búfer del
cuerpo de la respuesta y el stream llega todo de golpe. timeout=3600 es el
timeout de lectura de esta ruta, distinto del Timeout global del servidor.
Deja la autenticación al servidor MCP, así que nada de esto debería estar tras
Require valid-user; Apache reenvía Authorization al upstream cuando no la
consume él mismo. mod_remoteip con RemoteIPHeader X-Forwarded-For
corrige la dirección en los propios registros de Apache, y el servidor MCP sigue
necesitando --trusted-proxy-header y --trusted-proxies para su propio
limitador.
Módulos necesarios: proxy, proxy_http, ssl, headers, y remoteip si lo
usas.
Un túnel sustituye por completo al listener entrante: cloudflared marca hacia
fuera, así que la máquina no necesita ningún puerto abierto ni certificado
propio.
tunnel: <uuid-del-tunel>credentials-file: /etc/cloudflared/<uuid-del-tunel>.json
ingress: - hostname: mcp.example.com service: http://127.0.0.1:8080 originRequest: connectTimeout: 10s disableChunkedEncoding: false noTLSVerify: false - service: http_status:404disableChunkedEncoding: false es el valor por defecto, escrito explícitamente.
El ajuste rige la petición que cloudflared envía al origen, para un origen que
no sabe leer un cuerpo de petición troceado, y no la respuesta del origen, así
que el stream SSE no depende de él en ningún sentido.
Cloudflare pone CF-Connecting-IP, así que nombra esa:
--trusted-proxy-header=CF-Connecting-IP --trusted-proxies=127.0.0.1, ya que
cloudflared conecta desde la misma máquina. Prefiérela a X-Forwarded-For
aquí, porque Cloudflare la reescribe en cada petición y un cliente no puede
falsificarla a través del borde.
Dos comportamientos de Cloudflare que conviene comprobar antes de culpar al
servidor. El proxy aplica un límite de inactividad a una respuesta, y lo que
mantiene por debajo de él a un stream subscriptions/listen tranquilo es el
keep-alive del servidor: un comentario SSE (: keep-alive) escrito en el cuerpo
de la respuesta tras 25 segundos sin escribir, no un keep-alive a nivel TCP, así
que solo llega al borde si nada en el camino retiene el cuerpo. Y las propias
reglas de CORS y de caché de Cloudflare, si están activadas para ese nombre,
aterrizan exactamente en la misma colisión que un
Access-Control-Allow-Origin puesto por el proxy.
TLS entre el proxy y el servidor
Sección titulada «TLS entre el proxy y el servidor»Cuando el proxy comparte máquina, elimina el salto en lugar de cifrarlo:
--http-addr=/run/gitlab-mcp/server.sock. Cuando el proxy está en otro sitio, el
binario termina TLS por sí mismo y no hace falta ningún sidecar:
gitlab-mcp-server --http --http-addr=:8443 \ --tls-cert=/etc/ssl/mcp.crt --tls-key=/etc/ssl/mcp.key \ --gitlab-url=https://gitlab.example.comLas dos opciones o ninguna: --tls-cert requires --tls-key es un error de
arranque, no un aviso. TLS 1.2 es el suelo y no hay techo, así que un proxy
actual negocia 1.3 y todo lo anterior a 1.2 se rechaza. La mecánica de ambos
listeners está en
Escuchar en un socket unix o con TLS.
Exposición directa, sin proxy
Sección titulada «Exposición directa, sin proxy»El binario responde sus propios preflight de CORS, sus propios 404, sus
propias cabeceras de seguridad, y puede terminar TLS o escuchar en un socket
unix. Un despliegue sin proxy debería aun así ser deliberado en cinco cosas:
--auth-mode=oauth. Verificación del bearer contra la instancia que la petición seleccionó, con metadatos RFC 9728 para que los clientes la descubran.--public-urles obligatoria y debe ser el origen https accesible desde fuera.- Los certificados y su renovación. Se vuelven a leer en el handshake cuando los ficheros cambian, así que renovar es escribir un fichero y no reiniciar; consulta TLS entre el proxy y el servidor.
- Limitación de tasa.
--rate-limit-rpsvale 10 por defecto en modo HTTP con una ráfaga de 40, contada por token del pool y no por dirección, y un presupuesto aparte por dirección cubre los fallos de autenticación, que es el que responde429conRetry-After. Untools/calllimitado no es un429: es un resultado MCP normal que lleva un error, así que no aparecerá en la monitorización a nivel HTTP. - Límites.
--max-http-clientsacota las entradas del pool, no las sesiones ni las peticiones que retienen; el proceso acota las llamadas retenidas a la vez en todas las credenciales según su límite de descriptores (192 bajo un límite duro de 1024), sin opción de configurarlo, y rechaza la siguiente como ocupado (consulta Peticiones retenidas a la vez).--pool-idle-timeoutrecupera las entradas que no se usan, salvo una con una suscripción viva, que no está ociosa por mucho tiempo que haga desde su última petición.--session-timeoutsolo se aplica al modo con estado, donde el proceso además mantiene como mucho la mitad de sesiones que llamadas puede retener (96 bajo un límite duro de 1024) y rechaza el siguienteinitializecomo ocupado (consulta Sesiones con estado a la vez). Ninguno de los dos techos tiene uno por credencial a su lado, porque una credencial es una clave que quien llama puede acuñar, así que una sola credencial puede llenar cualquiera de ellos; y el servidor no tiene un tope de memoria propio, así que manténMemoryMaxen la unidad (la de arriba fija 512M) o pon un límite de memoria al contenedor, que es lo que acota la memoria donde el límite de descriptores es grande. - El token pasa por esta máquina. El token de GitLab de cada cliente llega a este proceso, autentica una petición y nunca se persiste. Si las personas dueñas de esos tokens consideran de fiar esa máquina es una pregunta que el software no puede responder, así que pregúntaselo en vez de responder por ellas.
Varias instancias tras un mismo proxy
Sección titulada «Varias instancias tras un mismo proxy»La afinidad es una preferencia, con dos excepciones
Sección titulada «La afinidad es una preferencia, con dos excepciones»Cada instancia mantiene dos cachés. Ambas son por proceso, ambas están indexadas por quien llama, y ninguna se puede compartir:
- El pool de servidores, indexado por
SHA-256(token + "\x00" + urlDeGitLab). En un fallo de caché se construye la entrada: se verifica la credencial, se leen sus ámbitos, se detecta el nivel de licencia de la instancia y la entrada se vincula al servidor MCP de su forma de configuración. El catálogo de herramientas no se construye por credencial: se registra una vez por forma y lo comparten todas las credenciales de esa forma (ADR-0020), así que su construcción (el primer cliente de una forma la esperó 0,32 segundos en la superficie dinámica y 1,28 en la individual en la medición de recursos, en la máquina que esta indica) se paga una vez por forma en cada instancia y no una vez por cliente. El saludo inicial se responde antes de que exista el catálogo, así que incluso esa primera construcción cae en la primera petición que necesita el catálogo y no eninitialize. - La caché de identidad OAuth, indexada por instancia y token, con
--oauth-cache-ttlen 15 minutos por defecto. Un fallo de caché es una ida y vuelta a GitLab para verificar la credencial.
Ambas guardan objetos vivos en lugar de estado serializable, así que no existe
ninguna versión de esto en la que una segunda instancia lea la caché de la
primera. La pregunta para un balanceador no es, por tanto, si las peticiones
funcionarán en cualquier sitio: con el --stateless=true por defecto cada POST
se basta a sí mismo y cualquier instancia responde correctamente. La pregunta es
cuántas veces estás dispuesto a pagar por una caché pensada para pagarse una, y
como su mitad cara se comparte por configuración en lugar de guardarse por
credencial, la respuesta es pequeña: un sondeo de la credencial, una consulta
de licencia y un cliente.
Dos cosas no solo cuestan de más cuando un cliente se mueve, se rompen. Una
sesión con estado (--stateless=false) vive en el proceso que acuñó su
Mcp-Session-Id, y otra instancia responde a ese identificador con 404, que un
cliente lee como que su sesión ha terminado. Las suscripciones a recursos son
observadores que mantiene un proceso y desaparecen con él. Si usas cualquiera de
las dos, la afinidad no es una optimización.
Las tres distribuciones
Sección titulada «Las tres distribuciones»| Distribución | Clave | Lo que cuesta |
|---|---|---|
| Round robin | ninguna | Toda instancia acaba con una entrada para cada cliente, así que el sondeo de la credencial, la consulta de licencia y la verificación del token de cada cliente se pagan una vez por instancia en lugar de una sola vez. El catálogo detrás de esas entradas es por forma de configuración, así que no se multiplica. Correcto, y paga N veces por la mitad barata |
| IP hash | la dirección del cliente | Gratis, una directiva, ningún secreto que guardar. La clave es la granularidad equivocada en ambos sentidos: una oficina, un concentrador VPN o una flota de runners de CI es una sola dirección, así que todo eso se agolpa en una instancia, mientras que un cliente que se mueve entre redes cambia de clave y aterriza frío cada vez. Tras una CDN necesita recuperar antes la dirección real |
| Hash del token | un resumen con sal de la credencial | Encaja con la granularidad de lo que se cachea, porque la clave del pool se deriva del token. Cuesta un secreto que gestionar, un plan alternativo para las peticiones sin credencial y una entrada fría cada vez que un cliente rota su token. Es lo que hace el endpoint alojado |
El hash del token es la respuesta correcta aquí, y la razón es lo bastante concreta como para decirla exacta: lo que se cachea está indexado por el token, así que la clave de enrutado también debería estarlo. Ninguna de las otras dos está mal, y ambas van bien allí donde una sola instancia atiende a toda la población de clientes, que es la mayoría de los despliegues. Ve a por una segunda instancia por disponibilidad, no por rendimiento: el techo que suele apretar es el propio límite de tasa de GitLab contra un token, que no sube por muchas instancias que pongas.
Resumir el token sin enrutar por él
Sección titulada «Resumir el token sin enrutar por él»La credencial no debe convertirse en la clave de enrutado. Una clave de afinidad acaba en la memoria del balanceador, en su registro de acceso si el formato nombra la variable, y en la selección de upstream que gobierne. Un token bearer en crudo en cualquiera de esos sitios es una fuga de credenciales con pasos extra.
El arreglo es un resumen con sal: mezcla un secreto propio del despliegue con la credencial y enruta por eso. El resumen es una función de distribución más que una primitiva de seguridad; la sal es la que hace el trabajo de seguridad. Sin ella la clave es una huella del token que cualquiera con un token candidato podría confirmar. Con ella la clave no significa nada fuera de este despliegue y no se puede reutilizar como credencial.
Tres detalles deciden si la afinidad se sostiene de verdad:
- Normaliza antes de resumir. Quita el prefijo de esquema
Bearery los espacios alrededor. El mismo token escrito de dos formas resume de dos formas y aterriza en dos instancias. - Recurre a la dirección, no a nada. Una petición sin credencial no tiene
clave, y una clave vacía hace que todas las peticiones anónimas resuman igual
y se amontonen en una instancia.
$remote_addrlas reparte. - Usa hashing consistente. Quitar una de tres instancias reubica entonces aproximadamente un tercio de los clientes en lugar de rebarajarlos a todos, que es la diferencia entre una actualización progresiva que calienta un pool frío y otra que los calienta todos.
Un balanceador nginx completo
Sección titulada «Un balanceador nginx completo»hash acepta una cadena con variables y la resume él mismo, así que la sal entra
en la expresión de la clave sin llegar a ser nunca una variable registrable por
su cuenta. Esto no necesita ningún módulo de terceros:
# Mantén la sal fuera del repositorio: inclúyela desde un fichero 0600.map $host $mcp_salt { default "una-sal-larga-y-aleatoria-por-despliegue";}
# La credencial bearer, sin su prefijo de esquema.map $http_authorization $mcp_bearer { default ""; "~*^Bearer[ ]+(?<tok>\S+)$" $tok;}
# Los clientes en modo legacy envían PRIVATE-TOKEN en su lugar.map $mcp_bearer $mcp_credential { default $mcp_bearer; "" $http_private_token;}
# Sin credencial: recurre a la dirección del cliente.map $mcp_credential $mcp_affinity { default $mcp_credential; "" $remote_addr;}
upstream gitlab_mcp { hash "$mcp_salt$mcp_affinity" consistent; server 10.0.0.11:8080 max_fails=2 fail_timeout=10s; server 10.0.0.12:8080 max_fails=2 fail_timeout=10s; server 10.0.0.13:8080 max_fails=2 fail_timeout=10s; keepalive 32;}
server { listen 443 ssl; server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
location / { proxy_pass http://gitlab_mcp; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Connection "";
proxy_buffering off; proxy_request_buffering off; proxy_read_timeout 1h;
# Reintenta solo lo que nunca se entregó. proxy_next_upstream error timeout; proxy_next_upstream_tries 2; }
# La ruta de metadatos derivada exacta, nunca un prefijo. location = /.well-known/oauth-protected-resource { proxy_pass http://gitlab_mcp; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Connection ""; }}No registres $mcp_affinity ni $mcp_credential. Donde se prefiera un resumen
explícito, set_md5 $key "$mcp_salt$mcp_affinity" de
ngx_http_set_misc_module (OpenResty, o el nginx-extras de Debian y Ubuntu)
produce uno, y njs hace lo mismo en unas pocas líneas. El endpoint alojado en
mcp.jmrp.io calcula así md5(sal + bearer).
Demuestra la afinidad en lugar de darla por supuesta. Dos instancias, ocho tokens, seis peticiones cada uno, leyendo el backend del registro de acceso:
for t in alpha bravo charlie delta echo foxtrot golf hotel; do for _ in $(seq 1 6); do curl -s -o /dev/null -H "Authorization: Bearer token-$t" \ https://mcp.example.com/health donedoneCada token debe mostrar exactamente un backend a lo largo de sus seis
peticiones, y los ocho tokens no deben mostrar todos el mismo. Repite el mismo
bucle sin cabecera Authorization y con PRIVATE-TOKEN en su lugar, para
confirmar que ambos planes alternativos también fijan.
Actualizaciones progresivas, sondas y salida
Sección titulada «Actualizaciones progresivas, sondas y salida»Anuncia el drenaje antes de cerrar el listener. Con SIGTERM el proceso se
marca primero como drenando: desde ese momento /health responde 503 con
"status": "draining" y Cache-Control: no-store. Por defecto el listener se
cierra justo después, así que un balanceador que sondea /health suele notar
el listener cerrado y no el 503, una sonda más tarde, y toda petición que
envió en esa ventana falló. Arranca cada instancia con --drain-delay fijado en
al menos un intervalo de sondeo (--drain-delay=10s para una sonda de 5
segundos que tolera dos fallos): el listener se mantiene abierto ese tiempo
respondiendo 503, el balanceador retira el backend, y solo entonces se cierra
el listener y las peticiones en vuelo reciben sus 15 segundos para terminar
antes de que se cierren las conexiones restantes. Con hashing consistente, los
clientes fijados a esa instancia pasan a una vecina, calientan allí una entrada
y vuelven cuando regresa. Un balanceador que no pueda sondear sigue funcionando
a la antigua: retira el backend a mano, deja drenar los streams y después
señala.
/health es la sonda, y es honesta sobre lo que sabe. Sin credencial, sin
ida y vuelta a GitLab, 200 con status, version, commit, build,
config_digest, started_at y uptime_seconds. status es ok mientras
sirve y draining una vez solicitada la parada; el estado HTTP lleva el mismo
veredicto. build es la etiqueta para un cuadro de mando, la release más
cercana más el commit corto, así que un binario de release y una compilación de
main se leen igual. config_digest es la comprobación de flota: todas las
instancias detrás de un balanceador deben publicar el mismo, o una de ellas
sirve un catálogo distinto a los clientes que le lleguen y nada más lo nota.
Compáralo en el mismo bucle que demuestra la afinidad. A propósito no comprueba
si GitLab está accesible, así que un balanceador no debe leer un 200 como
«GitLab funciona», y no hay un endpoint de disponibilidad aparte. Cada campo se
describe en
Verificación de salud.
Dale a la flota una dirección de salida fija. GitLab aplica sus propios límites de tasa y cualquier lista blanca de IP por dirección de origen. Unas instancias tras una pasarela NAT con dirección estable son un solo cliente para GitLab; unas instancias con direcciones públicas efímeras son varias impredecibles, y para ellas no se puede escribir una lista blanca.
Recuerda que el limitador se multiplica. --rate-limit-rps es por entrada de
token del pool dentro de un proceso, así que tres instancias significan hasta
tres cubos para un mismo cliente salvo que la afinidad lo fije a uno. Con
afinidad por token, el número configurado es el número. Con round robin, o lo
divides entre el número de instancias o pones el límite real en el balanceador.
La revalidación sigue corriendo por instancia. --revalidate-interval
vuelve a comprobar las credenciales del pool cada 15 minutos por defecto y
desaloja las que GitLab ya rechaza; ponerlo a 0 no hace que un token revocado
dure para siempre, porque una entrada de más de una hora se reconstruye en el
siguiente uso de todas formas. Eso es por instancia, así que el mismo token se
vuelve a comprobar una vez por cada instancia que lo tiene, y esa es una cosa
más que la afinidad mantiene en una.
Para seguir leyendo
Sección titulada «Para seguir leyendo»- Despliegues empresariales para el dimensionado a partir de mediciones, el balanceo a escala, los gateways MCP, la rotación de certificados y la operación
- Modo servidor HTTP para cada opción, el pool de servidores y las reglas de derivación de OAuth
- Aplicación OAuth para crear la aplicación de GitLab frente a la que se autorizan los clientes
- Seguridad para el modelo de amenazas, CORS y la lista de comprobación de fortificación
- Dimensionar un despliegue para lo que cuestan una credencial, una forma de configuración y una llamada
- OpenTelemetry para lo que una instancia puede contar sobre sí misma
- Solución de problemas para síntomas y sus causas habituales
Preguntas frecuentes
¿Puedo balancear varias instancias con round robin?
Para las llamadas normales funciona, y paga varias veces por la mitad barata de la caché. Cada instancia mantiene un pool de servidores indexado por un hash del token y la URL de GitLab, y una caché de identidad OAuth indexada por instancia y token. Ambas son por proceso y guardan objetos vivos, así que no se comparte nada entre instancias. A un cliente que aterriza en una instancia distinta en cada petición se le construye allí una entrada del pool (se leen sus ámbitos y se detecta el nivel de licencia) y se vuelve a verificar su token contra GitLab. El catálogo de herramientas no se reconstruye por él: una instancia registra el catálogo una vez por forma de configuración y lo comparte con todas las credenciales de esa forma. El round robin es correcto; simplemente paga N veces. Dos cosas sí necesitan afinidad, porque viven en un proceso: las sesiones con estado heredadas y las suscripciones a recursos. La afinidad por token fija a cada cliente en la instancia que ya tiene su entrada.
¿Por qué no usar simplemente IP hash para la afinidad?
Porque la dirección es la granularidad equivocada. Una oficina, un concentrador VPN o una flota de runners de CI es una sola dirección, así que todo eso se agolpa en una instancia; y un cliente que se mueve entre redes cambia de clave y aterriza frío cada vez. Tras una CDN el balanceador además tiene que recuperar primero la dirección real. El IP hash es barato y no necesita ningún secreto, lo cual es una ventaja real, pero lo que se cachea está indexado por el token, así que la clave de enrutado también debería estarlo.
¿Necesito un proxy inverso?
No. El binario responde sus propios preflight de CORS, sus propios 404, sus propias cabeceras de seguridad, termina TLS con --tls-cert y --tls-key, y presenta un certificado renovado en el siguiente handshake sin reiniciar. Cuando el proxy comparte máquina, --http-addr=/run/gitlab-mcp/server.sock elimina el salto en lugar de cifrarlo. Un proxy se gana su sitio por obtener certificados automáticamente, por alojar otros servicios en el mismo nombre y por balancear varias instancias.
Mis clientes OAuth reciben un 404 durante el descubrimiento. ¿Qué falla?
Casi seguro que el proxy solo enruta el prefijo de ruta. El documento de metadatos RFC 9728 vive en la raíz del host, no bajo la ruta de --public-url: arrancado con --public-url=https://mcp.example.com/gitlab, el servidor lo sirve en /.well-known/oauth-protected-resource/gitlab y responde 404 a /gitlab/.well-known/oauth-protected-resource/gitlab. /health y la ficha del servidor *sí* se montan además bajo el prefijo; los metadatos OAuth no. Enruta esa ruta derivada exacta, /.well-known/oauth-protected-resource/gitlab, al mismo upstream sin reescribirla, y nunca un prefijo de ella.