mcp-searxng-relay

Búsqueda web reforzada en MCP a través de tu propio SearXNG: autenticación por token, registros de auditoría por identidad, recuperación protegida contra SSRF, compilaciones de contenedores reproducibles.

Documentación

mcp-searxng-relay

Un servidor de Model Context Protocol (MCP) que brinda a los agentes de IA búsqueda web y recuperación de URLs a través de tu propia instancia SearXNG autoalojada, diseñado para entornos donde la búsqueda debe permanecer en infraestructura aprobada y cada consulta debe ser auditable. Sin APIs de búsqueda de terceros, sin intermediarios de datos externos; las consultas nunca salen de la infraestructura que controlas.

Para quién es. Equipos que ejecutan agentes de IA en entornos corporativos o gubernamentales donde la búsqueda saliente está restringida, monitoreada, o ambas — y donde "usamos una API de búsqueda alojada" no es una respuesta aceptable. El proyecto prioriza una postura de seguridad defendible y un rastro de auditoría limpio sobre la amplitud de funciones.

Qué lo distingue. La mayoría de las herramientas de búsqueda para agentes se detienen en "aquí hay algunos resultados." Este relay también te dice qué hizo el agente realmente con ellos.

searxng_session_sources devuelve las URLs que este relay genuinamente recuperó para un llamador determinado — byte-exacto, más reciente primero, cada una marcada con cuánto se leyó realmente: el texto completo, una ventana de un documento más largo, solo metadatos, o una recuperación fallida. Los agentes transcriben URLs mal cuando componen una respuesta final miles de tokens después de la llamada a la herramienta que la produjo, y las fabrican por completo cuando nunca recuperaron una. Una instrucción como "no cites fuentes que no has leído" es inaplicable contra el recuerdo de un modelo; contra esta lista es una búsqueda. La distinción de profundidad de lectura es la parte que importa — "recuperado" y "leído completo" no son la misma afirmación, y es precisamente la que los modelos pierden.

Una API de búsqueda alojada estructuralmente no puede ofrecer esto: ve una consulta a la vez y no mantiene un registro por llamador. El mismo razonamiento recorre el resto del proyecto — cada búsqueda y recuperación está atribuida a una identidad y una sesión, la política SSRF está documentada y su alcance se declara en la configuración en lugar de inferirse, y nada que amplíe un límite de seguridad puede ocurrir silenciosamente. Si necesitas poder decir qué buscaron tus agentes, qué leyeron y cuánto de ello, para eso es esto.

Proyecto complementario. Este relay está diseñado para desplegarse junto con searxng-helm, un chart Helm endurecido para SearXNG en Kubernetes (sin root, rootfs de solo lectura, NetworkPolicies de denegación por defecto, firmado con cosign). El chart despliega tanto SearXNG como este relay como un par; consulta su README para la historia completa de seguridad de infraestructura. El relay también incluye manifiestos K8s independientes mínimos para pruebas rápidas — ver Kubernetes abajo.

Este servidor MCP soporta tanto el transporte stdio (para uso local con Claude Desktop y clientes similares) como el transporte Streamable HTTP (para despliegues en red o contenedores).


Contenido


Características

  • Lista de fuentes verificablesearxng_session_sources devuelve las URLs que el relay realmente recuperó para un llamador, byte-exacto y más reciente primero, cada una marcada con cuánto se leyó: texto completo, una ventana de un documento más largo, solo metadatos, o una recuperación fallida. Los agentes transcriben URLs mal al componer una respuesta final lejos de la llamada a la herramienta que las produjo, y las fabrican por completo cuando nunca recuperaron una; esto le da al modelo verdad objetiva para copiar en lugar de recordar, y te da un registro de lo que realmente leyó. Entregado en un recuadro codificado en CDATA para que las URLs sobrevivan el viaje de ida y vuelta sin escapar.
  • Búsqueda web vía SearXNG con control total sobre idioma, categoría, rango de tiempo, nivel de búsqueda segura y cantidad de resultados
  • Recuperación de URLs con salida Markdown estructurada — encabezados, listas, tablas, bloques de código y énfasis en línea todos preservados
  • Triaje de metadatos de URLssearxng_url_metadata devuelve solo título, autor, fecha de publicación, idioma, nombre del sitio, descripción, imagen, categorías y etiquetas como JSON, a aproximadamente un orden de magnitud menor costo de tokens que recuperar el cuerpo completo. Útil para elegir cuál de varias URLs candidatas leer en su totalidad. La caché se comparte con searxng_read_url, por lo que una recuperación de metadatos seguida de una recuperación de contenido (o viceversa) cuesta una solicitud HTTP ascendente, no dos.
  • Extracción de texto PDF de URLs recuperadas
  • Extracción de documentos de oficina — DOCX, XLSX, PPTX más DOC, XLS, PPT heredados. Los documentos se renderizan a Markdown en lugar de texto plano para que los encabezados, tablas y la estructura de listas sobrevivan al contexto del modelo (las hojas de cálculo en particular se benefician — una tabla Markdown es mucho más útil que celdas aplanadas en CSV)
  • Paginación para documentos largos — las respuestas se dividen en ventanas de 100k caracteres, y una respuesta truncada termina con un aviso que nombra el tamaño total y el start_index exacto para la siguiente llamada. El texto extraído completo (hasta MAX_EXTRACTED_CHARS) se almacena en caché, por lo que paginar un PDF grande cuesta una recuperación ascendente, no una por página
  • Respuestas de imágenes — las URLs JPEG, PNG, GIF y WebP regresan como bloques MCP ImageContent para consumo de modelos de visión (el SDK codifica en base64 los bytes crudos en el cable). SVG está intencionalmente excluido — más útil para el modelo como texto que como blob binario. El tamaño crudo está limitado por MAX_IMAGE_BYTES, separado de MAX_BODY_BYTES, para que los límites de imagen y texto puedan ajustarse independientemente.
  • Detección automática de codificación — las páginas no UTF-8 (Shift-JIS, windows-1252, ISO-8859-1, …) se decodifican correctamente antes del análisis
  • Extracción de contenido estilo Readability — barras de navegación, pies de página, barras laterales y banners de cookies se eliminan automáticamente
  • Visibilidad de búsqueda degradada — SearXNG responde con HTTP 200 incluso cuando algunos de sus backends fallaron, por lo que una búsqueda devuelve silenciosamente resultados más escasos y el primer síntoma visible suele ser alguien concluyendo que el modelo ha regresado. El relay lee el campo unresponsive_engines que SearXNG reporta y registra un WARN nombrando los motores y por qué fallaron, para que un backend roto se diagnostique desde los propios registros del relay en lugar de confundirse con un error del relay o del modelo
  • Atribución de motores en resultados de búsqueda — cada resultado incluye la lista de motores backend SearXNG que lo devolvieron. Una URL surgida de tres motores es una señal diferente que una surgida de uno, y el agente puede sopesar eso sin que el servidor imponga una clasificación encima. El parámetro de búsqueda engines cierra el ciclo: un agente puede re-consultar el backend específico que surgió con un resultado prometedor.
  • Métricas de recuperación por dominio/metrics expone mcp_fetches_by_domain_total{domain="…",outcome="success|error"} para que un operador pueda ver qué hosts de destino están saludables y cuáles no. Cardinalidad limitada: como máximo 512 dominios distintos rastreados, con el resto agrupado bajo domain="__overflow__".
  • Caché de respuestas con TTL configurable y omisión de caché por solicitud
  • Protección SSRF — las direcciones no enrutables globalmente se bloquean al momento del dial TCP (loopback, link-local, privadas, multicast, broadcast, no especificadas, más una lista de bloqueo codificada que cubre CGNAT, TEST-NET-{1,2,3}, benchmark, asignaciones de protocolo IETF, NAT64, Teredo, 6to4, documentación IPv6, ORCHID, el prefijo de descarte, 240/4 reservado para futuro, y otros rangos reservados que los predicados de la stdlib pasan por alto). Las cadenas de redirección se revalidan en cada salto para cerrar la ventana de rebinding DNS. Los operadores pueden optar por alcanzar recursos internos (Confluence, Jira, wikis) vía FETCH_ALLOWED_HOSTS / FETCH_ALLOWED_CIDRS; ambos requieren un puerto explícito, por lo que permitir una wiki nunca expone también el listener de Redis o kubelet junto a ella.
  • Autenticación con token Bearer con tablas de múltiples tokens (MCP_AUTH_TOKEN, MCP_AUTH_TOKENS, o MCP_AUTH_TOKEN_FILE) y registro de auditoría por identidad
  • Limitación de velocidad por llamador — limitador de token-bucket con clave por identidad cuando está autenticado y por IP de origen en caso contrario. RPS y ráfaga configurables, por defecto 5 rps / ráfaga 10. Expuesto en mcp_rate_limit_rejections_total.
  • Vallado de prompts — cada respuesta de herramienta está envuelta en un elemento <sec:fence> firmado con un nonce aleatorio por respuesta, implementando arXiv:2511.19727. Clave pública expuesta en /fence/public-key para compatibilidad futura con clientes verificadores. La clave de firma es por proceso por defecto, o suministrada por el operador vía FENCE_SIGNING_KEY / FENCE_SIGNING_KEY_FILE cuando un verificador necesita una huella estable para fijar.
  • Construcciones de contenedor reproducibles — bit por bit. Dado el mismo commit fuente y SOURCE_DATE_EPOCH, la construcción produce una imagen byte-idéntica, verificable vía docker save <image> | sha256sum. Cadena de herramientas fijada por digest, go.sum congelado, sin rutas incrustadas, estado VCS o IDs de construcción. Detalles en supply-chain.md.
  • Banner de inicio estructurado con todos los valores de configuración impresos en stderr al inicio (secretos redactados)

Requisitos

  • Una instancia SearXNG en ejecución con el formato de salida JSON habilitado
  • Go 1.26+ (para construir desde fuente) o Docker

Habilitar formato JSON en SearXNG

Agrega lo siguiente a tu settings.yml de SearXNG:

search:
  formats:
    - html
    - json

Inicio rápido

Docker (recomendado)

docker run -d \
  -e SEARXNG_URL=https://your-searxng-instance.example.com \
  -e MCP_PORT=8080 \
  -e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
  -p 8080:8080 \
  ghcr.io/littleoffice/mcp-searxng-relay:latest

Docker Compose

services:
  mcp-searxng:
    image: ghcr.io/littleoffice/mcp-searxng-relay:latest
    restart: unless-stopped
    environment:
      SEARXNG_URL: https://your-searxng-instance.example.com
      MCP_PORT: "8080"
      MCP_AUTH_TOKEN: your-strong-random-token
    ports:
      - "8080:8080"

Construyendo la imagen del contenedor

Calcula las dos entradas de reproducibilidad una vez, luego elige tu herramienta de construcción:

SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct HEAD)"
SERVER_VERSION="$(git describe --tags --always)"

Docker (con BuildKit / buildx):

docker buildx build \
    --build-arg SERVER_VERSION="${SERVER_VERSION}" \
    --build-arg SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH}" \
    --output type=docker,rewrite-timestamp=true \
    -t mcp-searxng-relay:"${SERVER_VERSION}" .

Podman:

podman build \
    --build-arg SERVER_VERSION="${SERVER_VERSION}" \
    --build-arg SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH}" \
    --timestamp "${SOURCE_DATE_EPOCH}" \
    -t mcp-searxng-relay:"${SERVER_VERSION}" .

La construcción de múltiples etapas compila el binario en un constructor golang:1.26.6-trixie fijado por digest y copia solo el binario estático y los certificados CA en una imagen de runtime scratch.

Reproducibilidad. Dado el mismo commit fuente y SOURCE_DATE_EPOCH (canónicamente la marca de tiempo del propio commit), cualquiera de las dos invocaciones produce una imagen byte-idéntica — verificable vía docker save <image> | sha256sum o podman save <image> | sha256sum. La cadena de herramientas está fijada por digest de contenido, el grafo de módulos está congelado por go.sum, y la construcción establece -trimpath, -buildvcs=false, -buildid= y -Wl,--build-id=none para que ni rutas, estado VCS ni IDs de construcción en tiempo de enlace se filtren al binario. El rewrite-timestamp de BuildKit y el --timestamp de Podman fijan todas las marcas de tiempo de archivos de capa al mismo valor para que el sobre de imagen sea reproducible, no solo el binario dentro. Ver supply-chain.md para la declaración completa de procedencia y pasos de verificación.

Ten en cuenta que Docker y Podman usan codificaciones de manifiesto en disco ligeramente diferentes, por lo que las imágenes construidas con uno y guardadas a través del otro no tendrán SHA-256 coincidentes incluso cuando sean funcionalmente idénticas. Elige una herramienta de construcción y mantente con ella para verificaciones de reproducibilidad entre máquinas.

Kubernetes

Para producción, usa searxng-helm. El chart despliega SearXNG y este relay juntos con un contexto de seguridad restringido, NetworkPolicies con denegación por defecto, credenciales gestionadas por Secret y releases firmados con cosign. Es el despliegue de referencia para el modelo de amenazas para el que está construido este relay. Consulta su README para la historia completa de seguridad de infraestructura, el bloque de valores mcpRelay y las notas de integración con GitOps / external-secret-store.

Manifiestos independientes mínimos se incluyen en deploy/kubernetes/ para pruebas rápidas en el clúster sin un release de Helm: deployment.yaml con un securityContext restringido, service.yaml, kustomization.yaml y secret.example.yaml como plantilla para MCP_AUTH_TOKEN_FILE. Estos son intencionalmente mínimos — una sola réplica, sin Ingress, sin NetworkPolicy — y son un punto de partida, no un despliegue endurecido. Aplícalos con kubectl apply -k deploy/kubernetes/ después de crear un Secret real fuera de banda desde secret.example.yaml (copia a secret.yaml, completa los tokens, aplica una vez; deliberadamente no está listado en kustomization.yaml para que una re-aplicación no pueda revertir un Secret real a los valores de marcador de posición). La guía completa sobre la forma de despliegue, rotación de tokens y external-secret-store está en deploy/kubernetes/README.md.


Configuración

Toda la configuración se realiza mediante variables de entorno. El servidor se negará a iniciar si SEARXNG_URL no está establecido. Se requiere al menos uno de MCP_AUTH_TOKEN / MCP_AUTH_TOKENS / MCP_AUTH_TOKEN_FILE cuando MCP_PORT está establecido.

VariableRequeridoPredeterminadoDescripción
SEARXNG_URLURL base de tu instancia de SearXNG (la barra final se elimina automáticamente)
MCP_PORTnoPuerto para escuchar en modo HTTP. Si no se establece, el servidor usa stdio
MCP_AUTH_TOKENmodo HTTP¹Token bearer único; la identidad se registra como "default". Compatible con implementaciones de un solo inquilino
MCP_AUTH_TOKENSmodo HTTP¹Pares identity:token separados por comas para flotas estáticas pequeñas, p. ej. alice:abc...,bob:def...
MCP_AUTH_TOKEN_FILEmodo HTTP¹Ruta a un archivo con un identity:token por línea; los comentarios # y las líneas en blanco se ignoran
MCP_HEALTH_TOKENnoToken bearer opcional que controla GET /health. Un secreto separado de los tokens MCP anteriores: no reutilices un valor. Sin establecer (el valor predeterminado) deja /health abierto. Mismo mínimo de 32 caracteres. Si lo estableces, cada sonda debe enviarlo (consulta Punto final de salud)
MCP_METRICS_TOKENpara scrapeToken bearer que controla GET /metrics. Un secreto separado de los tokens MCP anteriores: no reutilices un valor. Sin establecer, /metrics devuelve 401 a todos, incluidos los llamadores con un token MCP válido. Mismo mínimo de 32 caracteres. Requerido si haces scrape de métricas (consulta Métricas)
MCP_TLS_CERTnoRuta a un certificado PEM. Con MCP_TLS_KEY, el relay sirve HTTPS directamente en lugar de HTTP plano. El par se recarga en caliente al cambiar el archivo, por lo que una renovación se detecta sin reiniciar. Mutuamente excluyente con las variables MCP_TLS_ACME_*. Consulta TLS
MCP_TLS_KEYnoRuta a la clave privada PEM para MCP_TLS_CERT. Ambos se requieren juntos; uno solo falla al iniciar
MCP_TLS_ACME_DOMAINSpara ACMENombres de host separados por comas que el certificado puede cubrir (la lista de permitidos de hosts ACME). Establecer esto (o cualquier variable MCP_TLS_ACME_*) activa ACME — no hay una bandera de activación/desactivación separada — y esta es entonces requerida. Los certificados se obtienen automáticamente, con desafíos servidos sobre TLS-ALPN-01 en el mismo puerto (no se necesita un segundo puerto). Mutuamente excluyente con MCP_TLS_CERT. Consulta TLS
MCP_TLS_ACME_EMAILnoDirección de contacto de la cuenta ACME. Opcional; si se establece, debe ser una dirección simple válida (p. ej. admin@example.com), o el inicio falla — una CA pública rechaza un contacto malformado en el registro. Déjalo sin establecer para registrarte sin contacto
MCP_TLS_ACME_DIRECTORYnoLet's EncryptURL del directorio ACME. Apúntalo a una CA privada (p. ej. step-ca) para usar una en lugar de Let's Encrypt
MCP_TLS_ACME_CACHE_DIRno/var/cache/mcp-acmeDirectorio donde se almacenan en caché los certificados emitidos para que sobrevivan a reinicios. Predeterminado a la ruta mostrada; monta un volumen, bind mount o PVC allí para hacerlo persistente (sin persistencia, los reinicios vuelven a solicitar y pueden alcanzar los límites de tasa de la CA). El inicio falla si la ruta no es escribible
MCP_TLS_ACME_CA_ROOTSnoPaquete PEM opcional que el cliente ACME debe confiar para un directorio ACME privado. Por defecto, la CA privada se confía a través del almacén de confianza del proceso (monta su raíz allí, o establece SSL_CERT_FILE); esta anulación en cambio confina esa confianza al cliente ACME, manteniéndola fuera de la herramienta de fetch y las rutas de SearXNG
MCP_TLS_HEALTHCHECK_INSECUREnofalseCuando la sonda --healthcheck habla HTTPS, omite la verificación de certificado. Predeterminado a false (verificar). Principalmente para TLS de certificado manual cuyo certificado no es válido para la dirección de loopback de la sonda; en modo ACME, la sonda presenta el primer dominio como SNI y verifica normalmente, por lo que esto no es necesario. Afecta solo a la autoprueba, no al punto final servido. Consulta TLS
MCP_STATELESSnofalseSi true, el SDK no emite IDs de sesión y trata cada solicitud como una sesión temporal nueva; el relay lee Mcp-Session-Id él mismo para correlación. Consulta "Modos de sesión" a continuación
MCP_SESSION_MAX_AGEno168hSolo modo con estado. Cuánto tiempo puede vivir una sesión antes de que el janitor la cierre. Sintaxis de duración de Go (30m, 12h, 168h — sin d o w)
MCP_SESSION_JANITOR_INTERVALno15mSolo modo con estado. Con qué frecuencia el janitor barre sesiones expiradas. Misma sintaxis de duración
MCP_RATE_LIMIT_RPSno5Tasa de solicitudes sostenida por llamador (solicitudes/segundo). Establece a 0 para deshabilitar. Se admiten valores fraccionarios (p. ej. 0.5 = una solicitud cada dos segundos)
MCP_RATE_LIMIT_BURSTno2 × RPS, min 1Capacidad de ráfaga del token bucket — el número de solicitudes que un llamador puede disparar consecutivamente antes de que la tasa sostenida entre en vigor
MCP_RATE_LIMIT_EXEMPTnoNombres de identidad separados por comas que omiten el limitador de tasa por completo (p. ej. ci,uptime-monitor). Útil para llamadores internos de confianza e identidades de monitoreo
AUTH_USERNAMEnoNombre de usuario de autenticación básica HTTP para SearXNG (si tu instancia lo requiere)
AUTH_PASSWORDnoContraseña de autenticación básica HTTP para SearXNG
SEARXNG_TOKENSnoTokens de motor privado separados por comas enviados como el parámetro de búsqueda tokens en cada consulta. Los motores que llevan una lista tokens: en settings.yml de SearXNG son invisibles e inutilizables sin uno. Limita este relay a un subconjunto de motores en una instancia compartida de SearXNG. Consulta Limitando un relay a motores específicos
USER_AGENTnomcp-searxng-relay/<version>Encabezado User-Agent enviado con todas las solicitudes salientes
CACHE_TTL_SECONDSno300Cuánto tiempo se almacena en caché el contenido de URL obtenido (segundos)
CACHE_MAX_ENTRIESno1000Número máximo de URL retenidas en la caché en memoria. Las entradas más antiguas se eliminan automáticamente cuando se alcanza el límite
MAX_BODY_BYTESno500000Tamaño máximo del cuerpo de respuesta leído de URL obtenidas (bytes)
MAX_PDF_BYTESno50000000Tamaño máximo del cuerpo de respuesta para URL de PDF (bytes). Los PDF obtienen un límite separado y más grande, ya que un documento de varios cientos de páginas puede fácilmente tener 50 MB
MAX_OFFICE_BYTESno50000000Tamaño máximo del cuerpo de respuesta para URL de documentos de Office (DOCX, XLSX, PPTX + DOC, XLS, PPT heredados) (bytes). Los archivos OOXML modernos son archivos ZIP que rutinariamente incrustan imágenes, fuentes y datos de gráficos, por lo que obtienen su propio límite separado de MAX_BODY_BYTES
MAX_IMAGE_BYTESno7500000Tamaño máximo bruto para respuestas de imágenes (bytes). La forma en el cable es ~33% más grande después de la codificación base64
MCP_HISTORY_ENTRIESno50Cuántas fuentes distintas searxng_session_sources retiene por llamador. Los espacios contienen fuentes, no fetchs, por lo que esto cuenta cosas que un agente podría citar. La restricción para aumentarlo es el contexto, no la memoria — la lista se lee en el contexto del modelo en cada llamada, a aproximadamente 40–80 tokens por entrada. Observa mcp_session_sources_elided_total para descubrir si tus agentes necesitan más
MAX_EXTRACTED_CHARSno1000000Límite en texto extraído retenido (y almacenado en caché) por URL, distinto de los límites MAX_*_BYTES en el cuerpo de respuesta bruto. Esto es lo que la paginación de searxng_read_url recorre; cada respuesta devuelve como máximo 100k caracteres de ello. Nota de memoria: en el peor caso, la caché contiene CACHE_MAX_ENTRIES × MAX_EXTRACTED_CHARS bytes de contenido (~1 GB en valores predeterminados, aunque las páginas reales rara vez se acercan al límite) — baja cualquiera de los valores en presupuestos de memoria ajustados, sube este para paginar más profundo en documentos muy grandes
EXTRACT_LINKSnotrueSi los objetivos de hipervínculo de HTML obtenido se muestran al modelo. Cuando está habilitado, los anclajes se renderizan como enlaces Markdown ([label](https://resolved-target)) tanto en prosa como en celdas de tabla, coincidiendo con lo que los documentos de Office ya producen. Los hrefs relativos se resuelven contra la URL de la página; solo se emiten objetivos http/https (javascript:, data: y similares se descartan). Establece a false para restaurar el comportamiento anterior de emitir solo texto de anclaje. No afecta a documentos de Office, cuyos enlaces pasan por el convertidor office_oxide de cualquier manera
PRUNE_SELECTORno[class*="related"], [id*="related"]Selector CSS cuyas coincidencias se eliminan antes de que trafilatura decida qué subárbol es el artículo. Sin él, los sitios que envuelven contenido de relleno en un contenedor atractivo pueden tener ese contenedor seleccionado en lugar de la historia — silenciosamente, con texto plausible y sin error. El predeterminado es el selector más estrecho medido para corregir un caso real (un artículo de Register donde la barra lateral más popular se extrajo en lugar del cuerpo) sin cambio en un artículo de heise. Establece a una cadena vacía para deshabilitar la poda. Un selector malformado falla al iniciar en lugar de ignorarse silenciosamente. Nota que header y footer están deliberadamente no incluidos: <article><header><h1> es HTML5 ordinario y podarlo decapita artículos
FETCH_ALLOWED_HOSTSnoEntradas host:port separadas por comas cuyos fetchs omiten la verificación SSRF de IP pública, para que la herramienta de fetch pueda alcanzar recursos internos nombrados (p. ej. confluence.corp:443,wiki.internal:8443). El puerto es obligatorio — un nombre de host simple falla al iniciar. Coincide exactamente en el nombre de host de la solicitud (insensible a mayúsculas y punto final; sin comodines de subdominio) y se re-verifica en cada salto de redirección. Consulta Protección SSRF
FETCH_ALLOWED_CIDRSnoEntradas range/prefix:port separadas por comas tratadas como alcanzables aunque la política predeterminada las bloquearía (p. ej. 10.1.2.0/24:443,192.168.5.0/24:8443). El puerto es obligatorio; una ruta predeterminada (0.0.0.0/0, ::/0) se rechaza. Se verifica contra la IP resuelta al momento de dial y en cada redirección, por lo que permanece robusto contra el rebinding de DNS. El tamaño de cada rango se registra al iniciar. Consulta Protección SSRF
FETCH_PROXYnoProxy de salida para la herramienta de fetch (http, https, socks5, socks5h), p. ej. http://proxy.corp:3128. Por sí solo se aplica solo a hosts en FETCH_ALLOWED_HOSTS. Deliberadamente no se lee de HTTP_PROXY/HTTPS_PROXY. Una URL malformada o esquema no soportado falla al iniciar. Consulta Protección SSRF
FETCH_PROXY_ALLnofalseEnruta cada fetch a través de FETCH_PROXY, no solo hosts en la lista de permitidos. Para redes sin salida directa. Delega la política SSRF por IP al proxy: FETCH_ALLOWED_CIDRS y la verificación de IP pública dejan de aplicarse. Establecerlo sin FETCH_PROXY falla al iniciar. Consulta Protección SSRF
FENCE_SIGNING_KEYnoClave privada Ed25519 usada para firmar elementos <sec:fence>, proporcionada en línea. Acepta PEM PKCS#8, DER PKCS#8 base64, una semilla base64 de 32 bytes, o una clave privada base64 de 64 bytes — la codificación se detecta automáticamente, y base64 con saltos de línea está bien. Cuando no se establece (el predeterminado), se genera una clave nueva en cada inicio de proceso. Mutuamente excluyente con FENCE_SIGNING_KEY_FILE: establecer ambos falla al iniciar, al igual que una clave malformada. Consulta Clave de firma de valla
FENCE_SIGNING_KEY_FILEnoRuta a un archivo que contiene el mismo material de clave, para montajes de Secret y podman secret. Mismas codificaciones y misma validación que FENCE_SIGNING_KEY. Un archivo legible más allá de su propietario registra una advertencia pero no falla al iniciar, ya que los montajes de solo lectura rutinariamente aterrizan en 0444. Consulta Clave de firma de valla
LOG_LEVELnoinfoVerbosidad de registro: debug, info, warn, error, off
LOG_FORMATnotextFormato de registro: text o json
¹ El modo HTTP requiere al menos una de las tres variables de token de autenticación. También pueden combinarse: las fuentes posteriores anulan a las anteriores si el mismo digest aparece en más de una. Todos los tokens se validan de forma independiente con un mínimo de 32 caracteres.

Genere un token seguro:

openssl rand -hex 32

Formato del archivo de tokens

Cuando se usa MCP_AUTH_TOKEN_FILE, cada línea que no sea un comentario es identity:token. La división se realiza en el primer :, por lo que los tokens pueden contener dos puntos; las identidades no. Las identidades son cadenas arbitrarias utilizadas únicamente para la correlación de registros (logs), normalmente un nombre de usuario, nombre de agente o etiqueta de cuenta de servicio.

# This is a comment.

alice:7f3a8c2e9b1d4f6a0c8e2b9d4f6a0c8e2b9d4f6a0c8e2b9d4f6a0c8e2b9d4f6a
bob:0e1d2c3b4a596877665544332211ffeedccbbaa998877665544332211ffeedc
service-ci:9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba

# Identity rotation: both lines below are accepted for "alice" until
# the old one is removed.  Useful for zero-downtime token rotation.
alice:newtokenvaluefor32charsminimum0123456789abcdef0123456789abcdef

Establezca el modo del archivo en 0600 y colóquelo en tmpfs (o en un secreto de Docker / volumen proyectado de Kubernetes) si su modelo de amenazas incluye a otros usuarios del host.

Modos de sesión

El transporte MCP Streamable HTTP es stateful (con estado) por defecto: el SDK asigna un ID de sesión en initialize, el cliente lo repite en cada solicitud posterior y el SDK lo busca en un mapa en memoria. Cuando el servidor se reinicia, ese mapa se reconstruye vacío: el ID de sesión antiguo del cliente devuelve 404, y muchos clientes MCP no logran reinicializarse automáticamente a pesar de que la especificación lo requiere. El resultado es: "He vuelto a implementar y mi agente está bloqueado hasta que lo reinicio".

ModoMCP_STATELESSCuándo usarloCompensación
Stateful (predeterminado)falseImplementación multiinquilino donde session_id debe ser emitido por el servidor y a prueba de falsificaciónEl agente debe volver a realizar el handshake después de cada reinicio del servidor
StatelesstrueImplementación que debe sobrevivir a reinicios del servidor sin reconexión del clientesession_id pasa a ser afirmado por el cliente (no validado por el servidor); GET/DELETE devuelven 405; las notificaciones iniciadas por el servidor no pueden llegar al cliente

Para la correlación de auditoría en modo stateful, cada línea de registro de llamada a herramienta lleva tanto identity (qué token autenticó la solicitud) como session_id (a qué handshake de inicialización pertenece la solicitud). El session_id conecta las llamadas a herramientas con la línea de registro "session initialized" de la misma sesión; ahí es donde se registra la identidad del cliente en el momento del handshake. Las sesiones inactivas se eliminan después de MCP_SESSION_MAX_AGE mediante un proceso de limpieza en segundo plano (por defecto, 7 días); las sesiones cerradas correctamente por el cliente (DELETE) también se rastrean y se liberan de inmediato.

En modo stateless, el campo session_id sigue presente y es estable entre solicitudes de un mismo cliente, pero proviene de otro lugar y significa algo más débil. A partir de go-sdk v1.7.0, un servidor stateless no lee ni establece Mcp-Session-Id en absoluto: el req.Session.ID() del propio SDK está vacío para cada solicitud y ServerOptions.GetSessionID no se consulta. (Antes de v1.7.0, el SDK repetía el valor del cliente; el cambio sigue la dirección sin sesión de la especificación MCP, SEP-2567).

Por lo tanto, en modo stateless este relay lee el encabezado por sí mismo, en un middleware, y solo en ese modo. El valor se valida en cuanto a forma: como máximo 128 bytes de ASCII imprimible y sin espacios, rechazado por completo en lugar de truncado; y luego se usa para exactamente dos cosas: el campo session_id en los registros de auditoría y la mitad de conversación de la clave por llamador detrás de searxng_session_sources. Sin él, dos agentes que comparten un token compartirían un libro de contabilidad de fuentes y expulsarían las entradas del otro.

Lo que es ese valor no ha cambiado: un cliente autenticado puede afirmar cualquier session_id que desee, por lo que es un identificador de correlación y nunca una afirmación sobre quién es el llamador. Lo que ha cambiado es quién lo lee: ahora es una elección deliberada del relay, no un comportamiento del SDK heredado por accidente. Un cliente que no envía ningún encabezado simplemente obtiene un session_id vacío, lo que es una degradación limpia en lugar de un fallo. identity sigue siendo validado por el servidor en ambos modos y es la clave de unión canónica cuando la resistencia a la falsificación importa; en modo stateless es la única cosa que separa a los inquilinos, ya que la mitad de conversación es completamente proporcionada por el cliente.

Si no desea IDs de sesión en sus registros en absoluto, hay dos casos. En modo stateful, establezca mcp.ServerOptions.GetSessionID a func() string { return "" } en server.go:buildMCPServer: el SDK entonces omite el encabezado de respuesta Mcp-Session-Id y req.Session.ID() devuelve vacío para cada solicitud: modo verdaderamente "sin sesión". La lectura de encabezado del propio relay está deliberadamente no conectada a esta ruta, por lo que no puede devolver los IDs proporcionados por el cliente que acaba de pedir que deje de registrar. En modo stateless, elimine el middleware trackClientSession de la cadena en main.go; GetSessionID no se consulta allí y no tendría efecto. Ninguno se expone como variable de entorno porque el caso de uso es limitado.

Ajuste del proceso de limpieza de sesiones

Los dos controles del proceso de limpieza sirven para diferentes propósitos y vale la pena entenderlos antes de cambiar los valores predeterminados:

  • MCP_SESSION_MAX_AGE es un ajuste de política. Limita cuánto tiempo puede vivir una sesión. Redúzcalo (p. ej., 24h) cuando su entorno rote los tokens de autenticación a diario: las sesiones más antiguas que el período de rotación están usando un token que ya no existe en la tabla, por lo que eliminarlas fuerza un handshake limpio con el token actual. Redúzcalo aún más para marcos de cumplimiento que requieran reautenticación periódica. Auméntelo (p. ej., 720h / 30 días) para agentes por lotes o programados que legítimamente permanecen inactivos durante largos períodos.

  • MCP_SESSION_JANITOR_INTERVAL es un ajuste de mecanismo. Controla con qué frecuencia se ejecuta el pase de limpieza. Intervalos más cortos detectan sesiones expiradas antes a costa de una pequeña cantidad de contención de mutex; intervalos más largos son más baratos pero permiten un mayor exceso más allá de MCP_SESSION_MAX_AGE. El valor predeterminado de 15m significa que una sesión podría vivir hasta 15 minutos más allá de su edad máxima antes de cerrarse; está bien para la política de "aproximadamente una semana", pero vale la pena reducirlo si su edad máxima es en sí misma corta.

Si no ve que el límite de sesión (mcp_active_sessions en /metrics) aumente bajo carga, los valores predeterminados están funcionando y no hay nada que ajustar.


Herramientas MCP

searxng_web_search

Ejecuta una búsqueda web y devuelve títulos, URL y fragmentos.

ParámetroTipoObligatorioPredeterminadoDescripción
querystringLa consulta de búsqueda
num_resultsnumberno10Número de resultados a devolver (máx. 20)
pagenonumberno1Número de página de resultados (máx. 100)
categoriesstringnogeneralCategorías de SearXNG separadas por comas: news, science, files, images, etc.
languagestringnoallCódigo de idioma, p. ej., en, de, fr
time_rangestringnoFiltrar por actualidad: day, month o year
safesearchnumberno0Nivel de búsqueda segura: 0 = desactivado, 1 = moderado, 2 = estricto
enginesstringnopredeterminado de la instanciaNombres de motores de búsqueda de SearXNG separados por comas para consultar, p. ej., wikipedia,github. Los nombres coinciden con la atribución de motores en resultados anteriores, por lo que un agente puede volver a consultar el backend que mostró un resultado prometedor. La entrada se convierte a minúsculas y se recorta el espacio en blanco; los nombres que la instancia no ejecuta se ignoran silenciosamente por SearXNG (una consulta que solo nombra motores desconocidos devuelve sin resultados en lugar de un error)

Ejemplo: noticias recientes en inglés:

{
  "query": "fusion energy breakthrough",
  "categories": "news",
  "language": "en",
  "time_range": "month",
  "num_results": 5
}

Forma de salida. Cada resultado se representa como un bloque de texto con la forma:

Title: Example article title
URL: https://example.com/article
Snippet: First sentence or two of the page…
Engines: google, bing, duckduckgo

La línea Engines se omite cuando SearXNG no devolvió el campo (versiones antiguas de SearXNG o resultados de una configuración de un solo motor). La lista refleja los motores que devolvieron esta URL, en el orden en que SearXNG los proporciona. No se calcula ninguna puntuación adicional: el agente es libre de leer el número de motores como una señal de corroboración o ignorarlo.


searxng_read_url

Obtiene una URL y devuelve su contenido. Maneja HTML (convertido a Markdown estructurado), PDF (texto extraído mediante pdf_oxide), documentos de Office (DOCX, XLSX, PPTX, además de DOC, XLS, PPT heredados, convertidos a Markdown mediante office_oxide), texto plano (decodificado por juego de caracteres) e imágenes (JPEG, PNG, GIF, WebP devueltas como bloques MCP ImageContent para el consumo de modelos de visión; SVG se excluye intencionalmente, ya que es más útil para el modelo como texto que como un blob binario codificado en base64). Almacena en caché los resultados por defecto; las respuestas de imagen omiten la caché de texto.

Los documentos largos se paginan. Cada respuesta devuelve una ventana de como máximo 100 000 caracteres del texto extraído; cuando hay más, la respuesta termina con un aviso como [content truncated — showing chars 0-100000 of 348211; call searxng_read_url again with start_index=100000 to continue]. El texto extraído completo (hasta MAX_EXTRACTED_CHARS) se almacena en caché en la primera obtención, por lo que las páginas siguientes son aciertos de caché y no cuestan una solicitud ascendente. Los desplazamientos en el aviso son exactos: el agente los repite textualmente; el servidor ajusta cualquier desplazamiento que dividiría un carácter multibyte y garantiza que cada página avance, por lo que seguir las pistas de continuación siempre termina.

El texto PDF está delimitado por líneas de marcador --- [PDF page N of M] ---, una por página, para que los agentes puedan responder "¿qué hay en la página 47?", citar números de página y orientarse dentro de cualquier ventana de paginación. Los marcadores son informativos: se encuentran dentro del límite de contenido no confiable, y un PDF malicioso puede incrustar texto similar (consulte SECURITY.md). Los documentos de Office no tienen marcadores de página: DOCX no tiene páginas intrínsecas (la paginación se calcula en el momento de la representación, no se almacena en el archivo), por lo que los encabezados de Markdown conservados por el convertidor son los anclajes de navegación allí; las diapositivas PPTX y las hojas XLSX aparecen como saltos de encabezado.

ParámetroTipoObligatorioPredeterminadoDescripción
urlstringLa URL a obtener (solo http/https)
force_refreshbooleannofalseOmitir la caché y obtener una copia nueva
start_indexintegerno0Desplazamiento en el texto extraído desde el que comenzar. Use el valor del aviso de truncamiento de una respuesta anterior
max_charsintegerno100000Caracteres del texto extraído a devolver en esta respuesta (máximo: 100 000)

Ambos parámetros de paginación se ignoran para URL de imágenes, que se devuelven completas como bloques de contenido de imagen.

Ejemplo: forzar una obtención nueva:

{
  "url": "https://example.com/article",
  "force_refresh": true
}

Ejemplo: continuar leyendo un documento largo desde donde se detuvo la última respuesta:

{
  "url": "https://example.com/big-report.pdf",
  "start_index": 100000
}

searxng_url_metadata

Obtiene solo los metadatos estructurados de una URL (título, autor, fecha de publicación, idioma, nombre del sitio, descripción, imagen, categorías y etiquetas) sin devolver el cuerpo de la página. Para PDF, page_count también se devuelve, para que un agente pueda evaluar si un candidato es un memo de 3 páginas o un informe de 400 páginas antes de comprometerse a una lectura completa (se omite deliberadamente para documentos de Office: DOCX no tiene un recuento de páginas intrínseco, ya que la paginación se calcula en el momento de la representación). Aproximadamente un orden de magnitud más barato en tokens que searxng_read_url, y está diseñado como un paso de triaje antes de comprometerse a leer una URL candidata en su totalidad. Los resultados se almacenan en caché y la caché se comparte con searxng_read_url: una obtención de metadatos seguida de una obtención de contenido (o viceversa) cuesta una solicitud HTTP ascendente, no dos.

ParámetroTipoObligatorioPredeterminadoDescripción
urlstringLa URL para obtener metadatos (solo http/https)
force_refreshbooleannofalseOmitir la caché y obtener una copia nueva

Ejemplo: triaje de tres candidatos antes de leer uno completo:

{ "url": "https://example.com/article-a" }
{ "url": "https://example.com/article-b" }
{ "url": "https://example.com/article-c" }

Forma de salida. Un objeto JSON con los campos de metadatos curados. Los campos que el extractor no pudo completar se omiten en lugar de mostrarse como cadenas vacías o null, por lo que la respuesta tiene forma variable; como mínimo, url siempre está presente:

{
  "url": "https://example.com/article",
  "title": "Example article title",
  "author": "Jane Doe",
  "description": "First paragraph or meta-description.",
  "site_name": "Example.com",
  "date": "2026-03-12T14:23:00Z",
  "language": "en",
  "image": "https://example.com/article/cover.jpg",
  "categories": ["technology"],
  "tags": ["distributed-systems", "go"]
}

Cuándo usar esto en lugar de searxng_read_url. Use searxng_url_metadata para clasificar cuál de varias URL candidatas vale la pena leer completa, para construir citas y para verificar fecha/autor/sitio cuando el cuerpo no es necesario. Use searxng_read_url una vez que se haya comprometido a leer una URL específica. Las dos herramientas comparten una caché, por lo que clasificar primero con metadatos y luego leer las URL elegidas completas no duplica la carga ascendente.


searxng_session_sources

Devuelve las URL que este relay ha obtenido para la identidad que llama, de la más reciente a la más antigua, con exactitud de bytes.

El problema que aborda no es la recuperación, sino la transcripción. Un modelo que compone una respuesta final con diez URL las está reproduciendo desde un contexto que pasó hace miles de tokens, token por token, sin nada contra qué verificar. Ese paso ocurre después de la última llamada a la herramienta, en un mensaje que ningún servidor MCP ve jamás, por lo que nada en el cable puede validarlo. Esta herramienta mueve los bytes correctos de vuelta a la posición inmediatamente anterior a que se escriba la respuesta, que es el único lugar donde un servidor puede ayudar. La misma lista responde al segundo fallo — una URL de apariencia plausible para una página que nunca se obtuvo — porque una URL ausente de la lista no fue obtenida.

ParámetroTipoObligatorioPredeterminadoDescripción
since_seqenterono0Devuelve solo las entradas con un número de secuencia superior a este. Pase el seq más alto de una llamada anterior para ver solo lo que se ha obtenido desde entonces

Forma de salida. Un objeto JSON, una fila por URL distinta en lugar de por obtención:

{
  "note": "URLs below are byte-exact as fetched by this relay …",
  "total_fetches": 12,
  "returned": 9,
  "elided": 0,
  "sources": [
    {
      "url": "https://www.example.com/psu/flex-atx-350w",
      "requested_url": "https://example.com/psu/flex-atx-350w",
      "title": "FlexATX 350W review",
      "read": "full",
      "outcome": "ok",
      "chars_read": 18422,
      "total_chars": 18422,
      "fetched_at": "2026-08-18T09:14:02Z",
      "tool": "searxng_read_url",
      "seq": 12,
      "fetches": 2
    }
  ]
}

read es el campo que distingue una fuente que un agente puede afirmar haber leído de una que solo miró: full (todo el texto extraído), partial (una ventana de paginación de un documento más largo), metadata (solo searxng_url_metadata — el cuerpo nunca se devolvió), image o none (la obtención falló). Las obtenciones fallidas aparecen con outcome: "error" y el texto del error; omitirlas haría que un 404 fuera indistinguible de una URL nunca intentada. requested_url aparece solo cuando una redirección movió la URL, y el url posterior a la redirección es el que se debe citar — es la única URL en el intercambio que el modelo nunca vio y, por lo tanto, no puede reconstruir en absoluto.

Las obtenciones repetidas se fusionan en una sola fila. Una URL obtenida más de una vez — clasificada con metadatos y luego leída, paginada ventana a ventana, o releída después de que la caché expirara — mantiene una sola fila, y fetches cuenta las llamadas detrás de ella. La fila entonces informa la lectura más profunda jamás alcanzada para esa URL y el punto máximo de chars_read / total_chars: una llamada posterior solo de metadatos no des-lee una página ya leída completa, y una re-obtención que falla no borra la copia que se devolvió. Los campos de actualidad (seq, fetched_at, tool, from_cache) siempre describen la obtención más reciente, que es lo que since_seq y el orden de más reciente a más antigua preguntan.

Alcance del historial. Por llamante (identidad + ID de sesión), en memoria, las 50 fuentes más recientes (MCP_HISTORY_ENTRIES). Los espacios contienen fuentes en lugar de obtenciones — una lectura de seis ventanas de un documento largo cuesta un espacio, no seis — por lo que el límite es un recuento de cosas que podría citar, no de llamadas a herramientas. Las fuentes descartadas para hacer espacio se informan como un recuento de elided; total_fetches sigue contando llamadas, por lo que legítimamente supera el número de filas. Clave tanto por identidad como por sesión importa: bajo MCP_STATELESS=true la mitad de la conversación es afirmada por el cliente (y vacía para un cliente que no envía Mcp-Session-Id), y en la configuración documentada sin sesión está vacía para todos — clave solo por ella permitiría que un llamante leyera las URL obtenidas de otro. En modo sin estado, un cliente que rota ese encabezado también acuña claves de caché libremente y puede expulsar los registros de otros llamantes de la caché de 1,000 entradas; eso es una degradación en lugar de una fuga, y mcp_history_callers_evicted_total es lo que lo hace visible. El historial no sobrevive a un reinicio y no cruza réplicas — solo necesita durar más que la conversación, y el almacenamiento compartido lo ampliaría a "todo lo que esta identidad haya obtenido alguna vez", lo que empeora la lista para su propósito en lugar de mejorarlo. Para implementaciones de múltiples réplicas, configure la afinidad de sesión en el ingreso.

Codificación de valla. Esta respuesta está envuelta en un <sec:fence> que lleva encoding="cdata". La valla escapada ordinaria convierte cada & en &amp;, que para una carga útil cuyo propósito completo son URL con exactitud de bytes es un canal de corrupción autoinfligido — y las URL densas en cadenas de consulta lo golpean en casi cada entrada. La firma aún cubre los bytes previos a la codificación exactamente como en la ruta escapada; encoding está dentro de la forma firmada canónica, por lo que un verificador puede saber cómo recuperarlos y un atacante no puede cambiarlo. La calificación permanece untrusted: el relay escribe la afirmación ("obtuve X en T") pero no los valores — los títulos provienen de páginas obtenidas — y marcarla como confiable permitiría que cualquier sitio blanqueara texto en una valla confiable al ser obtenido una vez.


Uso con Claude Desktop (modo stdio)

Agregue lo siguiente a su claude_desktop_config.json:

{
  "mcpServers": {
    "searxng": {
      "command": "/path/to/mcp-searxng-relay",
      "env": {
        "SEARXNG_URL": "https://your-searxng-instance.example.com"
      }
    }
  }
}

No se necesita MCP_PORT ni MCP_AUTH_TOKEN en modo stdio — el proceso se comunica a través de stdin/stdout y no es accesible por red.


Uso con Claude Desktop (modo HTTP)

Si prefiere ejecutar el servidor como un proceso en segundo plano persistente en lugar de generarlo por sesión:

{
  "mcpServers": {
    "searxng": {
      "type": "http",
      "url": "http://localhost:8080",
      "headers": {
        "Authorization": "Bearer your-strong-random-token"
      }
    }
  }
}

Nota: En cualquier implementación no local, el endpoint MCP debe alcanzarse a través de TLS — sus tokens de portador viajan en lo que lo envuelve. Ya sea frontalizándolo con un proxy inverso que termine TLS (nginx, Caddy, Traefik) o un Ingress, o haciendo que el relay sirva HTTPS él mismo con MCP_TLS_CERT/MCP_TLS_KEY o MCP_TLS_ACME_DOMAINS (ver TLS). Sin ninguno de estos, el relay sirve HTTP plano y registra una advertencia al inicio.


Limitando un relay a motores específicos

SEARXNG_TOKENS permite que varios relays compartan una instancia de SearXNG mientras cada uno alcanza solo sus propios motores — útil cuando equipos separados tienen backends de búsqueda internos separados y no deben leer los del otro.

Marque el motor como privado en settings.yml de SearXNG. tokens: controla quién puede seleccionar el motor; la credencial propia del motor (api_key o equivalente) es lo que limita lo que puede ver:

engines:
  - name: teama-confluence
    engine: json_engine
    base_url: https://confluence-a.corp/rest/api/search
    api_key: "<team A service account token>"
    shortcut: cfa
    categories: [general]
    disabled: true
    tokens: ['ENGINE-TOKEN-A']

Luego dé a cada relay solo su propio token:

docker run -d \
  -e SEARXNG_URL=https://searxng.corp \
  -e SEARXNG_TOKENS=ENGINE-TOKEN-A \
  -e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
  -e MCP_PORT=8080 -p 8080:8080 \
  ghcr.io/littleoffice/mcp-searxng-relay:latest

Notas:

  • disabled: true no es redundante. Sin él, el motor se sienta en su categoría y se dispara en cada búsqueda web ordinaria, agregando latencia y poniendo resultados internos frente a consultas no relacionadas. Nombrar un motor explícitamente a través del parámetro de búsqueda engines construye la referencia del motor directamente y no se ve afectado por el estado deshabilitado por defecto, por lo que el motor aún se ejecuta cuando realmente se le pide.
  • El límite lo aplica SearXNG, no este relay. SearXNG resuelve la lista completa de referencias de motores — categorías, el parámetro engines y la sintaxis !bang dentro de la cadena de consulta por igual — y solo entonces descarta los motores cuyos tokens: no están satisfechos. Un filtro en este proceso sobre el parámetro engines perdería la ruta de bang; presentar el token incorrecto no se puede eludir desde el lado del agente.
  • Los tokens son por proceso, no por llamante. Cada identidad en la tabla de tokens los comparte. Donde dos grupos de llamantes deben separarse, ejecute un relay por grupo. Las identidades en MCP_AUTH_TOKEN_FILE son etiquetas de auditoría, no un límite de autorización.
  • tokens como parámetro de consulta no está documentado aguas arriba. Los documentos de la API de búsqueda de SearXNG describen los tokens de motor solo como una configuración de la página de Preferencias. Que también se acepten como parámetro de solicitud se deduce de que webapp.pre_request fusiona request.args en las preferencias que analiza. Es un comportamiento de larga data, pero fije su imagen de SearXNG por digest y mantenga una prueba que afirme el caso negativo — una búsqueda que nombre el motor de otro equipo sin su token no devuelve resultados.
  • Solo búsqueda. searxng_read_url no usa estos tokens. Si el relay debe mantenerse alejado de los hosts internos de otro equipo, eso es FETCH_ALLOWED_HOSTS / FETCH_ALLOWED_CIDRS, establecido por relay.

Notas de seguridad

Inyección de prompts. Ambas herramientas devuelven contenido proveniente de la web abierta — títulos, fragmentos y cuerpos de página escritos por terceros. Un sitio malicioso puede incrustar instrucciones en ese contenido (incluso en elementos invisibles u ocultos) en un intento de secuestrar el comportamiento del agente, causar llamadas a herramientas inesperadas o exfiltrar el contexto de la conversación. Este es el riesgo principal en tiempo de ejecución al usar este servidor con un agente LLM.

Este servidor implementa la especificación de vallado de prompts de Peh, S. (2025), "Prompt Fencing: A Cryptographic Approach to Establishing Security Boundaries in Large Language Model Prompts" (arXiv:2511.19727). Cada respuesta de herramienta está envuelta en un elemento <sec:fence> con metadatos estructurados, precedido por un breve preámbulo de conciencia que le dice al modelo consumidor cómo interpretar el límite:

<sec:fence xmlns:sec="http://promptfence.org/security/1.0"
           signature="MEYCIQDx5w2l7..."
           kid="3f9a1c7e2b4d8056"
           nonce="a9f7e2c14b8d6f31..."
           rating="untrusted"
           source="https://example.com/article"
           timestamp="2026-05-07T14:23:00Z"
           type="content"
           version="1.0">
<extracted content>
</sec:fence>

Lo que esto proporciona hoy:

  • Identificación de clave y versionado de formato. Cada valla lleva kid — la misma huella informada por /fence/public-key — y version. kid permite que un verificador que tenga varias claves seleccione una en lugar de verificar por prueba contra todas, que es lo que hace viable la rotación de claves: las vallas firmadas por una clave saliente permanecen en la ventana de contexto y siguen llegando mientras la nueva clave se implementa, y sin kid "firmado por una clave que desde entonces retiré" y "forjado" ambos se presentan como "nada en mi conjunto verifica esto". Ambos atributos están dentro de la forma firmada canónica, por lo que un atacante no puede reescribir kid para nombrar una clave que controla, ni degradar version para alcanzar una ruta de verificación más antigua, sin invalidar la firma.
  • Protección contra escape de límites. Cada valla lleva un nonce aleatorio de 128 bits (de crypto/rand). Un atacante que controla el contenido obtenido no puede adivinar el nonce, por lo que no puede forjar una etiqueta de cierre que termine prematuramente la valla ni abrir una nueva valla "confiable" dentro de ella. El preámbulo de conciencia le dice al modelo consumidor que honre solo el límite identificado por el nonce por respuesta.
  • Firmas compatibles hacia adelante. Cada valla lleva una firma Ed25519 para que un futuro cliente verificador de vallas (o una puerta de enlace verificadora externa) pueda autenticar que el contenido vallado fue emitido por este proceso de servidor específico. Los bytes firmados son una serialización separada por dominio y con prefijo de longitud — "PromptFence/v1.0" || 0x00 || uint64_be(len(content)) || content || canonical_metadata — alimentada a PureEd25519 según RFC 8032 §5.1 (la operación de firma hashea el mensaje internamente con SHA-512; no pre-hasheamos). Esta es una desviación deliberada de la construcción literal Ed25519(SHA-256(C || M)) del §4.3 del artículo, que cambia silenciosamente el argumento de seguridad al alimentar un digest de 32 bytes en un esquema de firma que ya hashea su entrada. La etiqueta de dominio previene la confusión de firmas entre protocolos; el prefijo de longitud elimina la ambigüedad de límites que dejaría una concatenación content || canonical_metadata desnuda. El contenido se firma en su forma previa al escape XML, por lo que un verificador des-escapará XML del cuerpo del elemento analizado antes de verificar. El formato exacto en el cable está documentado en los bloques de comentario fence.go computeFenceSignature y buildFenceSigningInput. Ningún cliente MCP verifica actualmente estas firmas; están presentes para compatibilidad hacia adelante.

Limitaciones, declaradas honestamente:

  • Sin un verificador, las firmas no ofrecen ninguna garantía criptográfica. La protección contra escapes de límites proviene enteramente del nonce por respuesta.
  • El artículo sobre Prompt Fencing midió una prevención del 100% de inyección directa en su entorno experimental (n=300 intentos en dos modelos frontera), pero ese resultado depende del cumplimiento del modelo con el preámbulo de concienciación. Los modelos más pequeños o especializados pueden comportarse de manera diferente.
  • Los ataques semánticos — donde el contenido no confiable intenta persuadir en lugar de suplantar — no son abordados por ningún esquema de fencing.

Clave pública. La clave pública Ed25519 del servidor en ejecución se expone en GET /fence/public-key (modo HTTP, sin autenticación — una clave pública por definición no es un secreto). El banner de inicio imprime la huella de la misma clave, por lo que ambas pueden verificarse de forma cruzada. Ese campo fingerprint es también el valor que cada fence lleva como su kid, de modo que un verificador puede basar directamente su conjunto de claves confiables en él; el campo deliberadamente no se renombra a kid en la respuesta del endpoint, ya que cualquier cosa que ya lo analice espera fingerprint.

Clave de firma del fence. Por defecto, la clave de firma se genera de nuevo en cada inicio del proceso, por lo que la huella cambia entre ciclos de vida del proceso. Ese valor predeterminado es deliberado: sin un ancla de confianza externa (una CA, un conjunto JWK publicado, un KMS), persistir una clave implicaría una propiedad de continuidad que este servidor no puede garantizar por sí solo.

Tampoco es de utilidad para un verificador. Cualquier cosa que realmente verifique estas firmas — un cliente verificador de fences, o la pasarela de seguridad externa del artículo §4.5 — necesita una clave que pueda fijar. Frente a una clave que rota en cada reinicio, sus únicas opciones son volver a obtener /fence/public-key en el momento de la verificación, lo que reduce la comprobación a "firmado por quien respondió", o volver a fijar una huella manualmente después de cada despliegue.

Los operadores que ejecutan dicho verificador pueden, por tanto, proporcionar su propia clave, lo que coloca el ancla de confianza en su KMS o almacén de secretos en lugar de en este proceso:

# PKCS#8 PEM — the usual choice for a mounted Secret
openssl genpkey -algorithm ed25519 -out fence.pem

# or a bare 32-byte seed, if an inline env var is easier to manage
# (any 32 bytes is a valid Ed25519 seed)
openssl rand -base64 32
FENCE_SIGNING_KEY_FILE=/etc/mcp-auth/fence-key    # mounted file
FENCE_SIGNING_KEY="$(openssl rand -base64 32)"    # or inline

El banner indica qué modo está activo, de modo que una configuración incorrecta es visible de un vistazo en lugar de solo cuando un verificador comienza a rechazar fences:

fence key        3f9a1c7e2b4d8056 (persistent, from FENCE_SIGNING_KEY_FILE (PKCS#8 PEM))
fence key        a17c04e9b3f2d158 (ephemeral, rotates on restart)

El modo persistente también emite una línea warn al inicio, por la misma razón por la que se amplía la política SSRF: revierte un valor predeterminado deliberado y extiende el radio de impacto de una fuga de clave desde un ciclo de vida del proceso hasta "hasta que el operador rote". Rote esta clave con la misma cadencia con la que rota su otro material de firma — no hay caducidad automática.

Un despliegue con múltiples réplicas obtiene un segundo beneficio. Cada réplica genera su propia clave, por lo que un verificador que enfrenta un Servicio con balanceo de carga tendría que confiar en la clave de cada pod y reaprenderlas en cada lanzamiento. Una clave compartida de un solo Secret significa que todas las réplicas firman de manera idéntica.

Dos cosas que una clave persistente no hace, expresadas claramente:

  • No le otorga verificación. Hace que la verificación sea posible al darle al verificador algo estable para fijar. Nada en este servidor verifica firmas, y una firma que nadie verifica no ofrece ninguna garantía independientemente de cómo se gestione la clave.
  • No resuelve la distribución de claves, que es la mitad más difícil. Una pasarela que obtiene lo que /fence/public-key devuelve actualmente está confiando en el mismo endpoint que intenta autenticar: un atacante que pueda suplantar el relay también sirve su propia clave. Fije la huella fuera de banda, u obténgala una vez a través de un canal autenticado y alerte ante cambios. Tenga en cuenta también que el modo stdio no expone ningún endpoint HTTP, por lo que en ese modo la clave pública debe provenir del banner o derivarse de la clave privada que ya posee.

Para despliegues de alto riesgo, considere restringir las herramientas a una lista de dominios permitidos conocida, ejecutar el agente con un alcance de permisos mínimo y auditar las secuencias de llamadas a herramientas en su capa de aplicación.

Protección SSRF. La herramienta de obtención de URLs (searxng_read_url) resuelve nombres de host en el momento de la conexión TCP y rechaza cualquier dirección que no sea una IP unicast enrutable globalmente. Dos capas se ejecutan en cada conexión y en cada salto de redirección:

  1. Predicados de la biblioteca estándar: IsLoopback, IsLinkLocalUnicast, IsLinkLocalMulticast, IsPrivate (RFC 1918 + RFC 4193 ULA), IsUnspecified, IsMulticast y !IsGlobalUnicast (que detecta broadcast dirigido IPv4).
  2. Una lista codificada de CIDR reservados que los predicados de la biblioteca estándar no detectan, cada uno anotado con el RFC que lo reserva: 0.0.0.0/8 (RFC 1122), 100.64.0.0/10 CGNAT (RFC 6598), 192.0.0.0/24 asignaciones de protocolo IETF (RFC 6890), 192.0.2.0/24 / 198.51.100.0/24 / 203.0.113.0/24 TEST-NET-1/2/3 (RFC 5737), 192.88.99.0/24 anycast 6to4 obsoleto (RFC 7526), 198.18.0.0/15 benchmark (RFC 2544), 240.0.0.0/4 reservado para el futuro incluyendo 255.255.255.255 (RFC 1112), 64:ff9b::/96 y 64:ff9b:1::/48 NAT64 (RFC 6052/8215), 100::/64 prefijo de descarte (RFC 6666), 2001::/32 Teredo (RFC 4380), 2001:2::/48 benchmark IPv6 (RFC 5180), 2001:10::/28 y 2001:20::/28 ORCHID/ORCHIDv2 (RFC 4843/7343), 2001:db8::/32 documentación (RFC 3849), 2002::/16 6to4 (RFC 3056).

Ambas comprobaciones se ejecutan antes de que cualquier byte llegue al cable, y la cadena de redirección se revalida en cada salto, por lo que un atacante que controle el DNS de un host de apariencia pública no puede reenlazar a una dirección interna entre la comprobación y la conexión.

Acceso a recursos internos (opt-in). El valor predeterminado anterior bloquea todas las direcciones no públicas, que es la postura correcta para una herramienta que obtiene URLs influenciadas por atacantes. Los operadores que ejecutan el relay dentro de una red confiable y desean que lea recursos internos — un Confluence autoalojado, Jira, GitLab o wiki — pueden ampliar la política con dos listas de permitidos, ambas vacías por defecto (por lo que el comportamiento predeterminado no cambia):

  • FETCH_ALLOWED_HOSTS — entradas exactas de host:port que omiten la comprobación de IP pública. La coincidencia se realiza en el nombre de host de la solicitud, no en una IP resuelta, por lo que puede nombrar un host interno sin fijar su dirección; el llamante no puede falsificarlo (el nombre de host proviene de la URL que un llamante autenticado solicitó) y usted controla el DNS de sus propios nombres, por lo que esto no reabre el agujero de reenlace. La coincidencia no distingue entre mayúsculas y minúsculas ni entre puntos finales, y es exacta — confluence.corp:443 no coincide con sub.confluence.corp:443.

    El puerto es obligatorio. Escriba host:port; un nombre de host desnudo es un error de inicio. Esto se debe a que permitir solo un nombre de host entregaría a la herramienta de obtención cualquier otra cosa que esa máquina esté escuchando — Redis en 6379, etcd en 2379, un kubelet en 10250. Un llamante autenticado solo tiene que solicitar http://confluence.corp:6379/, y una redirección desde el servicio permitido alcanza los mismos lugares. Nombrar el puerto le hace declarar el alcance que realmente pretende:

    FETCH_ALLOWED_HOSTS=confluence.corp:443,wiki.internal:8443
    

    No tiene que escribir el puerto en las URLs. La entrada se compara con el puerto efectivo de la solicitud, con el valor predeterminado del esquema completado primero, por lo que confluence.corp:443 coincide con un https://confluence.corp/page simple y wiki.internal:80 coincide con http://wiki.internal/page. Un host que sirve ambos esquemas necesita ambas entradas (wiki.internal:80,wiki.internal:443); los puertos se acumulan por host en lugar de sobrescribirse.

    Los literales IPv6 toman la forma de URL entre corchetes: [fd00::1]:8443.

    Una entrada incorrecta — sin puerto, un puerto vacío o fuera de rango, o un valor que en realidad es una URL — falla al inicio con un mensaje que nombra la entrada y el formato esperado. Nunca se descarta silenciosamente: una línea de lista de permitidos que se analiza pero nunca puede coincidir es el peor resultado posible aquí, porque creería que se otorgó acceso y la obtención fallaría lejos de la configuración que lo causó.

  • FETCH_ALLOWED_CIDRS — rangos de IP tratados como alcanzables, escritos range/prefix:port. Se comprueban contra la IP resuelta en el momento de la conexión y en cada salto de redirección, por lo que sigue siendo seguro contra reenlace: un atacante que reenlaza un nombre de apariencia pública a una IP privada sigue bloqueado a menos que esa IP exacta caiga dentro de un rango que usted listó, en un puerto que usted listó.

    El puerto también es obligatorio aquí, y es más importante que para los nombres de host. Un nombre de host nombra una máquina, por lo que el puerto era toda su exposición. Un rango ya cubre muchas máquinas, y dejar el puerto abierto multiplica eso por 65535:

    EntradaDireccionesPares dirección:puerto alcanzables
    10.1.2.0/24:443256256
    10.1.2.0/24 (rechazada)25616,776,960
    10.0.0.0/8:44316,777,21616,777,216
    10.0.0.0/8 (rechazada)16,777,2161,099,494,850,560

    IPv6 funciona de la misma manera — fd00:1234::/64:8443. Los dos puntos no son ambiguos: un CIDR siempre termina en /<prefixlen>, y una longitud de prefijo son solo dígitos, por lo que el puerto es lo que sigue al último dos puntos después de la barra.

    Una ruta predeterminada se rechaza, no se advierte. 0.0.0.0/0 o ::/0 no amplía la política de direcciones, la elimina — loopback, link-local y el endpoint de metadatos de la nube se vuelven alcanzables, y la herramienta de obtención queda sin ninguna restricción de direcciones. Si la aplicación de la política realmente pertenece a otro lugar, dígalo con FETCH_PROXY_ALL, que es explícito sobre la delegación.

    La amplitud se informa, no se limita. Cada rango permitido se registra al inicio con el número de direcciones que cubre, y por separado si barre una dirección sensible:

    WARN fetch allow-list covers an IP range cidr=10.0.0.0/8 addresses=16777216 ports=443
    WARN fetch allow-list covers a sensitive address cidr=169.254.0.0/16 address=169.254.169.254
         what="cloud metadata endpoint (IMDS) — hands out instance credentials"
    

    Un /8 interno plano es inusual pero real, y rechazarlo empujaría a los operadores a FETCH_PROXY_ALL — lo que detiene la resolución de destinos del relay por completo. Un rango amplio pero visible es el mejor resultado. La distinción que dibuja la advertencia es deliberada versus barrida: 127.0.0.1/32:8080 es alguien que lo quiso decir; un /8 que resulta contener link-local es alguien que no miró.

    Prefiera FETCH_ALLOWED_HOSTS donde pueda. Un nombre de host nombra el único servicio que quiso decir. Recurra a un rango solo cuando genuinamente no pueda fijar los nombres.

Los dos son independientes (semántica OR): una obtención se permite si su host y puerto están en la lista de permitidos, o su IP resuelta es pública, o su IP resuelta y puerto están dentro de una entrada CIDR permitida. Ambos se reevalúan en cada salto de redirección, por lo que una redirección abierta en un host permitido aún no puede pivotar a una dirección interna bloqueada — ni, cuando la entrada está limitada por puerto, a un puerto diferente en el propio host permitido.

Dos precauciones al usar estos:

  • Un CIDR permitido anula todos los bloqueos predeterminados para las direcciones que cubre, incluyendo loopback y link-local. Listar un rango es una declaración explícita de que es seguro alcanzarlo. Mantenga los rangos ajustados — en particular, no liste 169.254.0.0/16 a menos que realmente pretenda exponer el endpoint de metadatos de la nube en 169.254.169.254.
  • Un CIDR malformado falla al inicio con un error claro en lugar de descartarse silenciosamente — un error tipográfico en un control de seguridad debe detener el servidor, no ampliarlo o reducirlo silenciosamente.

Cuando cualquiera de las listas no está vacía, el banner de inicio refleja la política ampliada (una fila fetch policy más los allowed hosts / allowed cidrs exactos que configuró), y se emite una línea de auditoría de nivel warn, por lo que es obvio en los registros que la herramienta de obtención ahora puede alcanzar objetivos internos y exactamente cuáles.

Acceso a recursos a través de un proxy de salida (opt-in). Algunas redes no dan al relay una ruta a un segmento interno, o ninguna ruta directa hacia afuera; la única forma de pasar es un proxy directo. Dos variables más optan, ambas sin configurar por defecto:

  • FETCH_PROXY — la URL del proxy. Por sí sola se aplica solo a hosts en FETCH_ALLOWED_HOSTS. Esto no cuesta nada en términos de cumplimiento: los hosts en lista de permitidos ya omiten la verificación por IP por diseño, por lo que enrutarlos a través de un proxy no renuncia a nada que siguiera funcionando. Cualquier otra búsqueda continúa conectándose directamente con la política completa de IP pública en vigor.
  • FETCH_PROXY_ALL — enruta todas las búsquedas a través del proxy. Esto es para despliegues sin salida directa, donde la alternativa no es una postura más estricta sino una herramienta que no funciona. Entiéndelo como una delegación: el proxy realiza la conexión y, por tanto, la resolución DNS, por lo que el relay nunca conoce la IP de destino, y assertPublicIP y FETCH_ALLOWED_CIDRS dejan de participar. Tu proxy de salida se convierte en el punto de cumplimiento. Los saltos de redirección también se dejan al proxy (el límite de saltos sigue aplicándose), porque una búsqueda local no podría restringirlos y esas redes a menudo no proporcionan ningún resolver al relay.

Ninguna variable se lee del entorno HTTP_PROXY / HTTPS_PROXY. Esas las establecen imágenes base, sistemas de CI y controladores de admisión de clústeres por razones no relacionadas, y respetarlas aquí permitiría que una variable que nadie configuró para este propósito cambiara silenciosamente un control de seguridad; además, llevan el valor predeterminado opuesto (proxy para todo excepto NO_PROXY) frente a la postura de denegación por defecto de este subsistema. El cliente de SearXNG aún las respeta, ya que su único destino está controlado por el operador y no conlleva exposición a SSRF.

Dos notas:

  • Las conexiones al proxy configurado omiten la verificación por IP — nombrarlo en FETCH_PROXY es en sí mismo la declaración de que es seguro alcanzarlo, y exigir 10.0.0.0/8 en FETCH_ALLOWED_CIDRS solo para alcanzar un proxy sería un intercambio mucho peor. Una búsqueda cuya URL destino nombre al proxy es rechazada, por lo que esta exención no puede alcanzarse desde una URL proporcionada por el llamante.
  • Un proxy limitado a hosts en lista de permitidos registra en info. FETCH_PROXY_ALL emite una línea de auditoría de nivel warn, y ambas aparecen como una fila de fetch proxy en el banner de inicio con cualquier contraseña en la URL redactada.

Autenticación. Los encabezados Authorization entrantes se procesan con SHA-256 una vez y se buscan en una tabla en memoria claveada por el SHA-256 de cada "Bearer <token>" configurado. La búsqueda opera sobre claves de longitud fija de 32 bytes, por lo que no puede filtrar la longitud del token mediante diferencias de tiempo de respuesta (una verificación de igualdad byte a byte haría un cortocircuito en el primer byte diferente). Solo los resúmenes permanecen en la memoria del proceso después del inicio — los tokens sin procesar solo se leen del entorno / archivo de tokens durante el análisis. Los tokens nunca aparecen en los registros; el banner de inicio muestra solo el número de tokens configurados e identidades distintas. En una coincidencia exitosa, la identidad asociada con ese token se adjunta al contexto de la solicitud y se registra en cada línea de registro de llamada a herramienta (identity=<name>) para correlación de auditoría.

Protección entre orígenes. El transporte HTTP Streamable está envuelto en net/http.CrossOriginProtection de Go por el go-sdk (v1.4.1+, aplicado como corrección para CVE-2026-33252 — "Cross-Site Tool Execution for HTTP Servers without Authorization"). Los POSTs originados en navegadores cuyos encabezados Sec-Fetch-Site o Origin indiquen una solicitud entre orígenes son rechazados, al igual que los POSTs sin Content-Type: application/json. Los clientes que no son navegadores — curl, Go http.Client, tráfico de agentes de IA — no envían ni Sec-Fetch-Site ni Origin y pasan sin verse afectados, por lo que el uso legítimo de agentes remotos no se ve impactado. Esto se suma a la autenticación por token portador, no la sustituye: la verificación entre orígenes se activa antes del procesamiento de la solicitud, pero cualquier solicitud que la supere aún debe presentar un token válido para llegar al manejador de MCP.

Endurecimiento del contenedor. La imagen de Docker se ejecuta como un usuario no root (UID 1001) sobre una base mínima de scratch — la imagen de ejecución contiene solo el binario enlazado estáticamente y los certificados CA, sin shell, gestor de paquetes ni espacio de usuario del sistema operativo.

Seguridad de PDF y Office. La extracción de PDF usa pdf_oxide y la extracción de Office (DOCX/XLSX/PPTX + DOC/XLS/PPT heredados) usa office_oxide, ambos núcleos en Rust que garantizan cero pánicos y cero tiempos de espera en todas las entradas. Un documento malformado o creado de forma adversaria devolverá un error, no bloqueará el proceso del servidor.

Informes y procedencia. Los problemas de seguridad deben informarse de forma privada — consulta SECURITY.md para el proceso de divulgación y el alcance. El código base es principalmente generado por IA y revisado, construido y probado por un único mantenedor humano antes del lanzamiento; supply-chain.md es la declaración completa de dependencias, procedencia de la compilación y proceso de desarrollo, escrita para revisores que evalúan el proyecto para un entorno controlado.


Límite de velocidad

En modo HTTP, el servidor aplica un límite de velocidad por llamante con cubeta de tokens a las solicitudes bajo /. Los valores predeterminados son 5 solicitudes por segundo sostenidas con una ráfaga de 10 — cómodo para un solo agente que razona con las herramientas (el patrón típico es de 1 a 3 llamadas a herramientas por turno de agente con segundos de tiempo de pensamiento del modelo entre ellas) mientras limita el daño que un agente descontrolado o un token filtrado puede causar. Establece MCP_RATE_LIMIT_RPS=0 para desactivarlo.

Las cubetas se clavean por identidad cuando la solicitud lleva un token portador reconocido, y por IP remota en caso contrario. La alternativa es intencional: un atacante no autenticado que fuerza tokens desde un solo host comparte una cubeta claveada por IP independientemente de qué intento de token presente, por lo que el limitador frena el ataque en el borde de la red en lugar de en la verificación de autenticación. Los llamantes autenticados se facturan contra su identidad — múltiples agentes que usan el mismo token comparten una cubeta, que es la semántica correcta para "presupuesto de uso de esta credencial".

Los rechazos devuelven HTTP 429 Too Many Requests con un encabezado Retry-After que contiene un recuento entero de segundos. Cada rechazo emite una línea de registro WARN estructurada con identity (cuando se conoce), remote, method, path y retry_after para que el rastro de auditoría registre el tráfico denegado de la misma manera que registra el tráfico no autorizado. El contador de Prometheus mcp_rate_limit_rejections_total agrega rechazos para paneles y alertas (sin etiqueta por identidad por diseño — los eventos de rechazo ya están en el registro estructurado cuando la investigación forense los necesita).

El almacén de cubetas es un LRU con un máximo de 10,000 entradas. Las identidades están limitadas por la tabla de tokens de autenticación configurada, por lo que todas caben cómodamente; el límite acota la memoria bajo un ataque de rotación de IP, a costa de que las cubetas desalojadas se reinicien a plena capacidad en el siguiente contacto (lo que no afecta materialmente la limitación para atacantes distintos).

Lo que esto no cubre. /health nunca se limita en velocidad para que un balanceador de carga de sondeo no pueda marcarse como abusivo. /metrics también está exento — un raspador que sondea en un intervalo fijo no debería producir huecos en Prometheus que parezcan caídas, y un raspador abusivo se contiene mejor rotando MCP_METRICS_TOKEN que enviando 429 al endpoint de métricas. Como ese token es separado de los tokens MCP, revocarlo no le cuesta al raspador nada más que su propio acceso. /fence/public-key no está autenticado ni limitado (es una clave pública, pública). El modo Stdio no tiene middleware HTTP y por tanto no tiene límite de velocidad, pero también es un único proceso de confianza sin superficie de ataque remota.

Lista de exentos. MCP_RATE_LIMIT_EXEMPT=ci,uptime-monitor omite el limitador por completo para esas identidades. Úsalo para agentes de monitoreo interno que acceden al endpoint raíz de MCP (en lugar de /metrics), y para pipelines de CI que ejecutan pruebas funcionales de alta frecuencia contra el servicio en vivo. Los tokens para identidades exentas deberían seguir viniendo de una fuente fuerte — la exención trata sobre volumen, no sobre confianza.

Notas de ajuste.

  • Agente único. Los valores predeterminados son suficientes. Un agente de razonamiento hace llamadas a herramientas de un solo dígito por turno, muy por debajo de 5 rps.
  • Muchos agentes concurrentes bajo una identidad. Si pones varios agentes tras un solo token, calcula (agents × peak-burst-per-agent) y establece MCP_RATE_LIMIT_BURST para cubrirlo, dejando MCP_RATE_LIMIT_RPS en el presupuesto sostenido por identidad que realmente quieres. O divide en una identidad por agente y deja que los límites se acumulen naturalmente.
  • Despliegues con múltiples réplicas. Las cubetas son por proceso. Bajo enrutamiento round-robin, la velocidad efectiva por llamante es (replicas × RPS); bajo enrutamiento con sesiones fijas es RPS. Si necesitas un presupuesto aplicado globalmente, termina en el Ingress y establece MCP_RATE_LIMIT_RPS=0 en los pods.
  • Público / orientado a internet. Ajusta el RPS a lo que sea una velocidad amigable con el upstream para SearXNG y mantén MCP_RATE_LIMIT_BURST cerca de eso — la ráfaga es lo que un atacante explotaría primero.

Límites de sesión

En modo HTTP, el servidor limita las sesiones concurrentes a 1,000. Las solicitudes para inicializar más allá de este límite reciben una respuesta 503 Service Unavailable. Las sesiones se eliminan cuando el cliente envía una solicitud DELETE.


Operaciones

Notas para ejecutar el servidor en producción. La mayor parte de esto vive en el código y los comentarios, pero es el tipo de detalle que un operador necesita antes del primer incidente, no después.

Endpoint de salud

GET /health es una sonda de vivacidad + preparación no autenticada. Devuelve:

EstadoCuerpoSignificado
200 OK{"status":"ok","searxng":"reachable"}El servidor está en ejecución y la instancia de SearXNG upstream respondió con HTTP < 500.
503 Service Unavailable{"status":"degraded","searxng":"unreachable"}El servidor está en ejecución pero la sonda de SearXNG upstream falló.

El resultado de alcanzabilidad del upstream se almacena en caché durante 10 segundos para que un balanceador de carga de sondeo no golpee SearXNG. El endpoint está abierto por defecto (las sondas no necesitan enviar un token portador) e intencionalmente no limitado en velocidad (un sondeador de LB de alta frecuencia nunca debería recibir 429 de /health).

Opcionalmente, requerir un token. Establece MCP_HEALTH_TOKEN para proteger /health tras un token portador — útil cuando el endpoint es alcanzable más allá del host local, ya que un /health abierto tanto divulga el estado de alcanzabilidad del upstream como (una vez por ventana de caché de 10 s) dispara una sonda contra SearXNG. El token es un secreto separado de los tokens portadores de MCP: la sonda y el endpoint de MCP son dominios de confianza diferentes, por lo que no deben compartir una credencial. Se valida contra el mismo mínimo de 32 caracteres, y un valor demasiado corto falla el inicio.

⚠️ Si estableces MCP_HEALTH_TOKEN, cada sondeador debe enviarlo. El token se aplica a todos los llamantes de /health, por lo que cualquier verificador de salud que no presente Authorization: Bearer <token> comenzará a recibir 401 y marcará el servicio como no saludable. Eso incluye balanceadores de carga externos, monitores de disponibilidad y sondas de Kubernetes httpGet. El único sondeador conectado automáticamente es la auto-sonda del contenedor --healthcheck, que lee la misma variable de entorno (ver abajo). Para una sonda de Kubernetes httpGet, añade el encabezado explícitamente:

readinessProbe:
  httpGet:
    path: /health
    port: http
    httpHeaders:
      - name: Authorization
        value: Bearer <your-health-token>

El deployment.yaml incluido usa /health solo como sonda de preparación; la vivacidad es una sonda TCP de socket simple. Esto es deliberado: una caída transitoria de SearXNG no debería escalar a que kubelet mate el pod, solo a que el tráfico se desvíe hasta que SearXNG se recupere. (Una sonda de vivacidad TCP de socket no necesita encabezado Authorization incluso cuando MCP_HEALTH_TOKEN está establecido, ya que nunca toca /health.)

Indicador CLI --healthcheck

La directiva HEALTHCHECK del contenedor en Dockerfile invoca a mcp-searxng-relay --healthcheck, que es una autosonda: el binario realiza una única GET a http://127.0.0.1:$MCP_PORT/health con un tiempo de espera de 5 segundos, sale con 0 si la respuesta es 200, y sale con 1 en caso contrario. La bandera existe porque la imagen de runtime scratch no tiene shell, curl, ni wget para escribir una sonda convencional — el binario tiene que ser su propia sonda. Cuando MCP_HEALTH_TOKEN está configurada, la autosonda lee esa misma variable de entorno y envía el encabezado Authorization: Bearer automáticamente, de modo que una sola entrada de entorno cubre tanto al servidor como a su propia sonda.

Esto es para despliegues simples de docker run / Compose. Kubernetes usa las sondas HTTP en deployment.yaml e ignora la directiva HEALTHCHECK.

Apagado gradual

Al recibir SIGTERM o SIGINT, el servidor deja de aceptar nuevas conexiones y luego da a las solicitudes en curso hasta 30 segundos para completarse antes de salir. El limpiador de sesiones (modo con estado) se detiene al mismo tiempo. Si la ventana de drenaje expira con solicitudes aún en curso, el proceso sale con código distinto de cero.

Dos perillas de despliegue interactúan con esto:

  • Kubernetes terminationGracePeriodSeconds. El valor predeterminado es 30s en la mayoría de los clústeres, lo que coincide exactamente con el tiempo de espera de drenaje — dejando cero margen para que kubelet entregue SIGTERM, el servidor lo reciba y la respuesta se vacíe. Establece terminationGracePeriodSeconds: 45 (o más) en la especificación del Pod para que el drenaje tenga una oportunidad real de completarse.
  • Compose stop_grace_period. El valor predeterminado es 10s, que es más corto que el tiempo de espera de drenaje del servidor. Establece stop_grace_period: 45s en el servicio para que SIGKILL no llegue a mitad del drenaje.

Para despliegues con múltiples réplicas detrás de un Ingress o balanceador de carga, el LB necesita dar de baja el Pod antes de que llegue SIGTERM — de lo contrario, el tráfico continúa llegando durante la ventana de drenaje. Kubernetes maneja esto automáticamente una vez que las sondas de preparación comienzan a fallar, que es una de las razones por las que /health es la sonda de preparación y no la de vivacidad.

Tiempos de espera del servidor HTTP

El http.Server de la biblioteca estándar del servidor está configurado con tres valores deliberados:

ConfiguraciónValorRazón
ReadTimeout30sLimita cuánto tiempo un cliente lento puede mantener la línea de solicitud, los encabezados y la lectura del cuerpo. Suficientemente largo para cuerpos JSON-RPC típicos; suficientemente corto para desalentar ataques estilo slowloris.
WriteTimeoutdeshabilitado (0)El go-sdk gestiona los plazos por flujo para las respuestas SSE. Un plazo de escritura a nivel de servidor cerraría prematuramente flujos de eventos de larga duración durante llamadas a herramientas que toman más de unos pocos segundos.
IdleTimeout120sVentana de inactividad de keepalive. Por encima del tiempo de pensamiento típico del cliente entre llamadas a herramientas; por debajo del punto en que las conexiones muertas se acumulan.

Al poner un proxy inverso delante del servidor (recomendado para cualquier despliegue no local — ver Notas de seguridad), los tiempos de espera del propio proxy deben acomodar respuestas de streaming:

  • nginx. Establece proxy_read_timeout y proxy_send_timeout al menos al tiempo de pared más largo de llamada a herramienta que esperes — un agente de razonamiento sobre un PDF grande o documento de Office puede tomar 30+ segundos. Deshabilita proxy_buffering para la ruta MCP para que los fragmentos SSE lleguen al cliente inmediatamente.
  • Caddy. El Caddyfile incluido establece flush_interval -1 en la directiva reverse_proxy de MCP, que es lo que deshabilita el almacenamiento en búfer de respuestas de Caddy para streaming.
  • Traefik. Usa el campo forwardingTimeouts.responseHeaderTimeout y asegúrate de que el entrypoint no esté configurado con un tiempo de espera de inactividad agresivo.

Si ves llamadas a herramientas fallando con flujos SSE truncados en un despliegue con proxy inverso, el tiempo de espera de lectura/escritura del proxy es casi siempre la causa, no el relay.

TLS

Por defecto, el relay habla HTTP plano y TLS es terminado por lo que lo precede — el servicio Caddy en el stack podman, un Ingress en Kubernetes. Esa sigue siendo la forma recomendada dondequiera que ya exista tal terminador. Para un despliegue sin proxy — el relay ejecutándose solo — también puede servir HTTPS directamente, en uno de dos modos (mutuamente excluyentes; configurar ambos falla el arranque):

Certificado manual. Apunta MCP_TLS_CERT y MCP_TLS_KEY a un certificado PEM y una clave:

docker run -e MCP_PORT=8443 -e MCP_TLS_CERT=/tls/tls.crt -e MCP_TLS_KEY=/tls/tls.key ...

El par se carga una vez al arranque (una ruta incorrecta o un certificado/clave no coincidentes fallan el arranque, no el primer handshake) y se vuelve a leer en el siguiente handshake cuando los archivos cambian — de modo que una renovación en el lugar (cert-manager reescribiendo un Secret montado, un hook de despliegue de certbot) se recoge sin reinicio.

Certificados automáticos (ACME). No hay bandera de activación/desactivación — nombrar los hostname(s) a certificar con MCP_TLS_ACME_DOMAINS activa ACME:

docker run -e MCP_PORT=443 \
  -e MCP_TLS_ACME_DOMAINS=relay.example.com \
  -e MCP_TLS_ACME_EMAIL=admin@example.com \
  -v mcp-acme:/var/cache/mcp-acme ...

Establecer cualquier variable MCP_TLS_ACME_* selecciona el modo ACME, y MCP_TLS_ACME_DOMAINS es entonces requerido — de modo que una configuración ACME a medias (una variable extraviada o mal escrita) falla el arranque de forma ruidosa en lugar de caer silenciosamente a HTTP plano. Los certificados se almacenan en caché bajo MCP_TLS_ACME_CACHE_DIR (por defecto /var/cache/mcp-acme); monta un volumen, bind mount o PVC allí para que sobrevivan a los reinicios — sin persistencia, los reinicios re-solicitan y pueden alcanzar los límites de tasa de la CA, y el arranque falla si la ruta no es escribible. Los desafíos se responden sobre TLS-ALPN-01 en el mismo listener, de modo que solo el puerto TLS necesita ser alcanzable — sin respondedor :80.

  • Emisión y registro al arranque. El relay contacta a la CA al arranque, solicitando un certificado para cada host MCP_TLS_ACME_DOMAINS tan pronto como el listener está arriba (en lugar de perezosamente en el primer handshake del cliente), de modo que una mala configuración surge inmediatamente. Observa el registro para ello — en info obtienes acme: enabled (el directorio, hosts, directorio de caché y cómo se confía en la CA), luego acme: requesting certificate / acme: certificate ready (o acme: certificate request failed con el error) por host; en LOG_LEVEL=debug cada handshake — incluyendo el desafío TLS-ALPN-01 de la propia CA — se registra. Los errores de handshake TLS de clientes reales también se registran. Si no ves ninguna línea acme:, ACME no se activó — verifica que MCP_TLS_ACME_DOMAINS esté establecida y escrita correctamente, y que estés ejecutando una compilación que incluya esto (ya no hay una bandera de activación/desactivación MCP_TLS_ACME). La CA debe poder alcanzar el puerto TLS del relay para completar el desafío; si tu servidor ACME no registra ninguna solicitud entrante, esa alcanzabilidad (DNS/firewall/enrutamiento al listener) es lo primero a verificar.

  • Correo de contacto. MCP_TLS_ACME_EMAIL es opcional. Déjalo sin establecer para registrar la cuenta ACME sin contacto; si lo estableces, da una dirección válida simple — una CA pública (Let's Encrypt) rechaza un contacto malformado en el registro, y el relay verifica la forma de la dirección al arranque para que esa falla surja inmediatamente en lugar de en la primera emisión.

  • Alcanzabilidad del desafío (could not connect to validation target). La CA valida sobre TLS-ALPN-01 conectándose al host en tcp/443 — el puerto 443 está fijado por RFC 8737, sea cual sea el puerto en que el relay escuche. Así que <host>:443 (para cada nombre en MCP_TLS_ACME_DOMAINS) debe resolverse, desde la red de la CA, a este relay y ser alcanzable a través de cualquier firewall/NAT. Si el relay escucha en un puerto no-443, publícalo para que el :443 del dominio aún enrute (por ejemplo, -p 443:8443). Un acme:error:connection / "no se pudo conectar al objetivo de validación" en el registro significa que esta ruta está rota, no el relay — verifícalo desde el host de la CA con openssl s_client -connect <host>:443 -alpn acme-tls/1 -servername <host>.

  • CA privada (por ejemplo, step-ca). MCP_TLS_ACME_DIRECTORY selecciona el directorio ACME (por defecto: producción de Let's Encrypt). Si el certificado del endpoint del directorio de esa CA no es de confianza pública, la ruta más simple es añadir su raíz al almacén de confianza del contenedor (incrustarla en la imagen, o establecer SSL_CERT_FILE) — luego deja MCP_TLS_ACME_CA_ROOTS sin establecer y ACME usa el almacén de confianza del sistema. Establece MCP_TLS_ACME_CA_ROOTS=/path/to/ca-roots.pem solo cuando quieras que esa confianza esté confinada al cliente ACME para que la CA privada no sea también confiada por la herramienta de fetch y el cliente SearXNG. Esto es el equivalente en proceso de la configuración ACME que el Caddyfile incluido ya usa.

  • Verificación de salud. La autosonda --healthcheck (usada por el HEALTHCHECK de Docker) sigue al servidor a HTTPS cuando TLS está activado. En modo ACME presenta el primer host MCP_TLS_ACME_DOMAINS como el SNI TLS mientras aún dialoga con 127.0.0.1, de modo que el servidor puede servir su certificado real (un SNI de IP de loopback es rechazado por la política de host ACME) y la sonda verifica contra ese hostname — no se necesita configuración adicional una vez que el certificado ha sido emitido. En modo manual la sonda dialoga con 127.0.0.1 directamente, de modo que el certificado servido debe ser válido para la dirección de loopback (añade 127.0.0.1/localhost como SANs) para que la verificación pase; MCP_TLS_HEALTHCHECK_INSECURE=true omite la verificación cuando no puede. De cualquier manera, esto afecta solo a la autosonda de loopback, nunca al endpoint servido.

En Kubernetes, prefiere terminar TLS en un Ingress con cert-manager (ingress.example.yaml); la ruta MCP_TLS_* dentro del pod está ahí para ejecutar el relay solo en un namespace sin Ingress (ver el README de Kubernetes).


Construyendo la imagen Docker

docker build -t mcp-searxng-relay .

La compilación multi-etapa compila el binario en el builder golang:1.26.6-trixie con digest fijado y copia solo el binario estático y los certificados CA en una imagen de runtime scratch.


Registro

Toda la salida de registro va a stderr. Establece LOG_FORMAT=json para registro estructurado compatible con agregadores de registros.

Al arranque, el servidor imprime un banner de configuración a stderr independientemente del nivel de registro. El banner lista todos los ajustes activos con secretos redactados. AUTH_USERNAME solo se muestra cuando está establecido.

######################################################################################################################

mcp-searxng-relay v1.0.0

######################################################################################################################

mode             streamable-http
address          :3000
searxng          http://searxng:8080
password         [not set]
user-agent       Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36
cache ttl        5m0s
cache entries    1000 max
body limit       500000 bytes
pdf limit        50000000 bytes
office limit     50000000 bytes
image limit      7500000 bytes
log level        info
log format       text
session mode     stateless
auth tokens      3 configured (3 identities)
rate limit       5 rps, burst 10
fence key        3e21267250e41cbb

######################################################################################################################

Una vez que el servidor está en ejecución, las líneas de registro típicas se ven así (modo con estado, LOG_FORMAT=text):

time=2026-05-24T07:41:10.301Z level=INFO msg="url fetched" url=https://github.com/asgeirtj/system_prompts_leaks content_type="text/html; charset=utf-8" bytes_raw=372821 chars_extracted=5469
time=2026-05-24T07:41:10.302Z level=INFO msg="fetch completed" url=https://github.com/asgeirtj/system_prompts_leaks kind=text identity=zed session_id=O3GD67SQIYXDYN57XCVQMZYKDI
time=2026-05-24T07:43:39.212Z level=INFO msg="search completed" query="site:github.com/asgeirtj/system_prompts_leaks \"Claude Code\" system prompt" page=1 results=10 categories="" identity=zed session_id=O3GD67SQIYXDYN57XCVQMZYKDI
time=2026-05-24T07:43:52.249Z level=INFO msg="url fetched" url=https://github.com/asgeirtj/system_prompts_leaks/blob/main/Anthropic/claude-code.md content_type="text/html; charset=utf-8" bytes_raw=500000 chars_extracted=185
time=2026-05-24T07:43:52.253Z level=INFO msg="fetch completed" url=https://github.com/asgeirtj/system_prompts_leaks/blob/main/Anthropic/claude-code.md kind=text identity=zed session_id=O3GD67SQIYXDYN57XCVQMZYKDI
time=2026-05-24T07:44:07.656Z level=INFO msg="url fetched" url=https://raw.githubusercontent.com/asgeirtj/system_prompts_leaks/main/Anthropic/claude-code.md content_type="text/plain; charset=utf-8" bytes_raw=58874 chars_extracted=58873
time=2026-05-24T07:44:07.657Z level=INFO msg="fetch completed" url=https://raw.githubusercontent.com/asgeirtj/system_prompts_leaks/main/Anthropic/claude-code.md kind=text identity=zed session_id=O3GD67SQIYXDYN57XCVQMZYKDI

Una búsqueda donde algunos backends de SearXNG fallaron no es un error — el upstream responde 200 con lo que los motores sobrevivientes produjeron — pero es una respuesta degradada, y se registra como tal:

level=WARN msg="searxng search was degraded: some engines did not respond"
  unresponsive_engines=google,bing unresponsive_count=2
  detail="google: Suspended: Access denied; bing: timeout"
  query="..." results=7
  hint="results are incomplete; check the named engines in your SearXNG instance
        before treating thin results as a relay or model problem"

Los motores se rompen — el markup upstream cambia, una API se deprecia, un muro de captcha se levanta — y SearXNG los suspende y continúa. Sin esta línea, la degradación es invisible en toda la pila: menos resultados llegan al agente, sus respuestas empeoran, y nada en ningún lugar dice por qué. Los despliegues con muchos motores configurados verán entradas intermitentes a medida que los motores pasan por suspensión; eso es ruido que vale la pena tener, porque la alternativa es el silencio.

El mismo evento incrementa mcp_searches_degraded_total y mcp_searxng_engine_errors_total{engine="…"} (ver Métricas): la línea de registro es cómo diagnosticas un incidente, los contadores son cómo descubres que hay uno — un WARN que nadie busca con grep no es monitoreo.

El campo session_id une cada llamada a herramienta de vuelta a la línea "session initialized" donde el identity del cliente se registró por primera vez; combinados forman la pista de auditoría. La línea "unauthorized request" muestra cómo se ve un intento fallido de token bearer — el valor Authorization rechazado nunca se registra, solo la dirección remota. En LOG_FORMAT=json los mismos campos aparecen como un objeto JSON plano por línea, que es lo que la mayoría de los agregadores de registros esperan.


Métricas

En modo HTTP, GET /metrics devuelve contadores en formato de texto Prometheus, controlados por MCP_METRICS_TOKEN.

⚠️ Se requiere MCP_METRICS_TOKEN para hacer scraping. Sin él, /metrics devuelve 401 a todos los llamadores, incluido uno que tenga un token MCP válido. Establézcalo a un valor de openssl rand -hex 32 y dé ese valor a su scraper:

MCP_METRICS_TOKEN=<openssl rand -hex 32>

No debe ser uno de sus tokens MCP. mcp_fetches_by_domain_total nombra hasta 512 nombres de host de destino que este relay ha obtenido, en todos los llamadores. Servido a la tabla de tokens MCP, eso permite que cada inquilino lea qué hosts están leyendo todos los demás inquilinos — y en un relay con FETCH_ALLOWED_HOSTS configurado, esos son sus nombres de host internos. Un scraper no es un inquilino y un inquilino no es un scraper; la credencial los separa en ambas direcciones.

El endpoint está cerrado en lugar de abierto por defecto porque un límite que solo existe una vez configurado no es un límite — fallaría silenciosamente en cada despliegue que aún no hubiera leído este párrafo. /health toma el defecto opuesto (abierto a menos que MCP_HEALTH_TOKEN esté establecido) porque divulga dos campos fijos, no el perfil de salida de la flota.

La fila metrics auth del banner de inicio informa CLOSED cuando no hay token establecido, y el servidor registra una línea de advertencia al inicio diciéndolo, de modo que un panel en blanco sea diagnosticable desde este extremo en lugar de desde el scraper.

Las series expuestas son:

SerieEtiquetasNotas
mcp_searches_totalTodas las llamadas a searxng_web_search
mcp_search_errors_totalSubconjunto de lo anterior que devolvió un error
mcp_metadata_totalTodas las llamadas a searxng_url_metadata
mcp_metadata_errors_totalSubconjunto de lo anterior que devolvió un error
mcp_session_sources_totalTodas las llamadas a searxng_session_sources. Léase como una proporción frente a mcp_fetches_total: indica con qué frecuencia los agentes verifican sus URLs antes de responder
mcp_session_sources_elided_totalLlamadas que devolvieron una lista incompleta, es decir, un agente puede haber respondido contra un registro que ya no contenía todo lo que leyó. Un valor persistentemente distinto de cero es la señal para aumentar MCP_HISTORY_ENTRIES
mcp_history_callers_evicted_totalLlamadores cuyo historial completo fue eliminado de la caché de 1,000 entradas. Un aumento frente a un recuento estable de llamadores significa que alguien está generando claves de caché — en modo sin estado, la mitad de conversación de la clave es afirmada por el cliente, por lo que un cliente que rota Mcp-Session-Id puede expulsar a todos los demás
mcp_history_evictions_totalFuentes eliminadas del historial de un llamador para hacer espacio. Indica cuánto más allá del límite operan los llamadores; por sí solo puede ser un llamador ocupado que nunca lee su lista de vuelta, así que ajuste según el contador elidido anterior
mcp_fetches_totalTodas las llamadas a searxng_read_url
mcp_fetch_errors_totalSubconjunto que devolvió un error
mcp_fetches_by_type_totaltype=html|pdf|office|plain|imageObtenciones exitosas por extractor utilizado
mcp_fetches_by_domain_totaldomain=<host>, outcome=success|errorContadores de éxito/fallo por dominio
mcp_cache_hits_totalSolicitudes searxng_read_url servidas desde caché
mcp_cache_misses_totalSolicitudes que cayeron en una obtención de red
mcp_cache_force_refresh_totalSolicitudes con force_refresh=true
mcp_rate_limit_rejections_totalSolicitudes HTTP rechazadas por el limitador de tasa por llamador (respuestas 429). Los detalles del rechazo — identidad, remoto, reintento — están en el registro WARN estructurado; sin etiqueta por identidad aquí por diseño
mcp_ssrf_blocked_totalreason=loopback|link_local|private|unspecified|multicast|non_global_unicast|reservedMarcados de obtención/redirección rechazados porque el objetivo resolvió a una dirección no pública, por clase. Este es el límite de salida hecho visible; un pico es un agente (o atacante) sondeando direcciones internas/de metadatos de nube. El CIDR reservado coincidente y la IP infractora permanecen en el registro de depuración, nunca en esta etiqueta ni en ninguna respuesta al llamador
mcp_auth_failures_totalendpoint=mcp|metrics|healthSolicitudes HTTP rechazadas con 401 en cada superficie con compuerta. Un pico es sondeo de credenciales o un scraper/probe mal configurado (p. ej., un scraper que aún recibe 401 porque MCP_METRICS_TOKEN no está establecido — el caso de endpoint cerrado cuenta bajo endpoint="metrics"). El remoto infractor está en el registro WARN; sin etiqueta por remoto aquí
mcp_searches_degraded_totalLlamadas searxng_web_search que devolvieron HTTP 200 pero nombraron motores que no respondieron. Léase como una proporción frente a mcp_searches_total — el número único que indica si la inestabilidad del backend es ruido de fondo o lo que está empeorando las respuestas de sus agentes. No es un error, por lo que mcp_search_errors_total deliberadamente no las ve
mcp_searxng_engine_errors_totalengine=<name>Fallos por backend de SearXNG, del campo unresponsive_engines ascendente. Responde cuál motor una vez que la proporción anterior indica que hay un problema. Limitado a 256 nombres distintos; el resto se agrega bajo engine="__overflow__"
mcp_active_sessionsMedidor: sesiones MCP vivas actuales (solo modo con estado)
mcp_search_duration_secondsleHistograma: latencia de ida y vuelta de búsqueda de SearXNG. Cubos de 50ms a 30s
mcp_fetch_duration_secondsleHistograma: latencia de la tubería de obtención de URL (marcado hasta extracción), observada tanto para searxng_read_url como para searxng_url_metadata. Incluye aciertos de caché, que caen en el cubo más bajo — alerte en cuantiles superiores (p. ej., histogram_quantile(0.99, ...)) y lea el p50 junto a mcp_cache_hits_total. El cubo superior coincide con el tiempo de espera del cliente de obtención de 30s, por lo que las observaciones de +Inf son solicitudes adyacentes a tiempo de espera

Cardinalidad por dominio

mcp_fetches_by_domain_total está limitado a 512 dominios distintos. Una vez alcanzado ese límite, los destinos únicos adicionales se agregan bajo el valor de etiqueta sintético domain="__overflow__" en lugar de expandir aún más el conjunto de etiquetas. El límite es una elección de diseño deliberada: un agente que obtiene muchos hosts únicos no debería poder hacer crecer la memoria del proceso o el índice de Prometheus sin límite.

Si el contador de desbordamiento es distinto de cero en su entorno, o su flota de agentes toca legítimamente más de 512 dominios (en cuyo caso aumente maxTrackedDomains en metrics.go y reconstruya) o algo está mal con las consultas que está dando a la herramienta (en cuyo caso el desbordamiento está haciendo su trabajo al señalarlo). Los operadores que quieran una auditoría completa de cada URL obtenida deberían confiar en las líneas de registro de obtención estructuradas (url=…) en lugar del contador de métricas; la métrica es observabilidad, no procedencia.

Lo que la métrica por dominio no es

No es una entrada de lista de bloqueo que el servidor lea de vuelta. El proyecto no bloquea automáticamente dominios según tasas de fallo — esa decisión pertenece al operador. El flujo de trabajo previsto es: el operador revisa los recuentos de fallo por dominio en su configuración de Prometheus / Grafana, decide qué hosts (si alguno) eliminar y actualiza su configuración estática en consecuencia. En comparación con un sistema que muta su propio comportamiento, esto mantiene el comportamiento del servidor en cualquier momento dado como una función de su configuración únicamente, que es lo que lo hace auditable.