Construyendo un servidor MCP para la documentación de servicios

created: viernes, oct 10, 2025

Como proyecto divertido, queríamos exponer nuestra documentación oficial —que también está alojada aquí— como un servidor MCP. La idea era probar si esto facilitaría el desarrollo de nuestra propia plataforma, ya que a nuestros IDEs basados en IA les resultaría más sencillo comprobar el conocimiento arquitectónico contra un endpoint público fácil de consumir.

Ahora, empezando de forma muy ingenua, ideamos el siguiente plan para implementarlo.

Como breve adelanto, esto es lo que queremos construir.

flowchart LR
  X[Internet]
  subgraph downtozero.cloud
    A[downtozero.cloud]
  end
  subgraph W[Servidor MCP]
    N[Frontend Axum]
    T[búsqueda tantivy]
    M[Servidor MCP]
    N -- /index.json --> A
    N -- query --> T
    N -- MCP request --> M
  end
  X -- search 'registry' --> N
  X[Internet] -- GET /index.html --> A

Steps

  1. Exportar el sitio web en un formato más fácil de consumir
  2. Consumir y buscar el contenido relevante
  3. Construir un servidor MCP con capacidad de búsqueda

Paso 1 - Exportar el sitio web

El sitio actual está construido con Hugo. Así que todo el contenido de este sitio se crea como Markdown y luego se renderiza a HTML. Para los navegadores, HTML es un buen formato. Para motores de búsqueda así como para LLMs, esto desperdiciaría bastantes recursos y tendría solo un impacto mínimo en la calidad del resultado. Pero, especialmente, los LLMs son realmente buenos leyendo y entendiendo Markdown. Por eso quedó claro que queríamos alimentar al LLM con Markdown. Al mismo tiempo, necesitábamos consumir estos datos para nuestro servidor de búsqueda.

Dado que Hugo soporta múltiples formatos de salida e incluso formatos arbitrarios, empezamos a construir una salida JSON. La idea era renderizar todas las páginas que tenemos en un único JSON grande y ver qué obteníamos.

[
  {
    "contents": "We always aim ..",
    "permalink": "https://downtozero.cloud/posts/2025/scale-to-zero-postgres/",
    "title": "Scale-To-Zero postgresql databases"
  },
  {
    "contents": "Eliminating Wasted Cycles in Deployment At DownToZero, ...",
    "permalink": "https://downtozero.cloud/posts/2025/github-deployment/",
    "title": "Seamless Deployments with the DTZ GitHub Action"
  },

Ahora, para crear algo así, Hugo necesita tener una plantilla en su lugar. Así que colocamos el siguiente archivo en el directorio de plantillas por defecto. layouts/_default/index.json

{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages }}
  {{- /* start with an empty map */ -}}
  {{- $page := dict -}}
  {{- /* always present */ -}}
  {{- $page = merge $page (dict
        "title"     .Title
        "permalink" .Permalink) -}}
  {{- /* add optional keys only when they have content */ -}}
  {{- with .Params.tags }}
      {{- if gt (len .) 0 }}
          {{- $page = merge $page (dict "tags" .) -}}
      {{- end }}
  {{- end }}
  {{- with .Params.categories }}
      {{- if gt (len .) 0 }}
          {{- $page = merge $page (dict "categories" .) -}}
      {{- end }}
  {{- end }}
  {{- with .Plain }}
      {{- $page = merge $page (dict "contents" .) -}}
  {{- end }}
  {{- $.Scratch.Add "index" $page -}}
{{- end }}
{{- $.Scratch.Get "index" | jsonify -}}

Para que esta plantilla se renderice, tuvimos que añadir la salida JSON al config.toml.

baseURL = 'https://downtozero.cloud/'
title = 'Down To Zero'

[outputs]
  home = ["HTML", "RSS", "JSON"]

Paso 2 - Consumir y buscar

Ahora que tenemos el JSON construido, está disponible en el sitio a través de /index.json.

Obtener el contenido más actualizado se volvió fácil, lo que nos llevó a la pregunta abierta de implementar la búsqueda. Dado que estamos construyendo toda nuestra pila sobre contenedores serverless, con un backend principalmente en Rust, nuestra elección aquí también recayó en un contenedor Rust.

Así que elegimos https://github.com/quickwit-oss/tantivy. La API de este motor es directa y no nos preocupan demasiado los casos límite ni los pesos.

Aquí hay un breve fragmento de código que muestra la recuperación y la indexación.

fn test() {
  let data = fetch_data().await.unwrap();
  let index = build_search_index(data);
  let results = search_documentation(index, "container registry".to_string());
}

async fn fetch_data() -> Result<Vec<DocumentationEntry>, reqwest::Error> {
    let response = reqwest::get("https://downtozero.cloud/index.json")
        .await
        .unwrap();
    let text = response.text().await.unwrap();
    log::debug!("text: {text}");
    let data = serde_json::from_str(&text).unwrap();
    Ok(data)
}

fn build_search_index(data: Vec<DocumentationEntry>) -> Index {
    let schema = get_schema();

    let index = Index::create_in_ram(schema.clone());
    let mut index_writer: IndexWriter = index.writer(50_000_000).unwrap();

    for entry in data {
        let doc = doc!(
            schema.get_field("title").unwrap() => entry.title,
            schema.get_field("contents").unwrap() => entry.contents.unwrap_or_default(),
            schema.get_field("permalink").unwrap() => entry.permalink,
            schema.get_field("categories").unwrap() => entry.categories.join(" "),
            schema.get_field("tags").unwrap() => entry.tags.join(" "),
        );
        index_writer.add_document(doc).unwrap();
    }
    index_writer.commit().unwrap();
    index
}

fn search_documentation(index: Index, query: String) -> Vec<(f32, DocumentationEntry)> {
    let reader = index
        .reader_builder()
        .reload_policy(ReloadPolicy::OnCommitWithDelay)
        .try_into()
        .unwrap();
    let searcher = reader.searcher();
    let schema = get_schema();
    let query_parser = QueryParser::for_index(
        &index,
        vec![
            schema.get_field("title").unwrap(),
            schema.get_field("contents").unwrap(),
            schema.get_field("permalink").unwrap(),
            schema.get_field("categories").unwrap(),
            schema.get_field("tags").unwrap(),
        ],
    );
    let query = query_parser.parse_query(&query).unwrap();
    let top_docs = searcher.search(&query, &TopDocs::with_limit(10)).unwrap();

    let mut results = Vec::new();
    for (score, doc_address) in top_docs {
        let retrieved_doc: TantivyDocument = searcher.doc(doc_address).unwrap();
        let entry = DocumentationEntry {
            title: retrieved_doc
                .get_first(schema.get_field("title").unwrap())
                .unwrap()
                .as_str()
                .unwrap()
                .to_string(),
            contents: Some(
                retrieved_doc
                    .get_first(schema.get_field("contents").unwrap())
                    .unwrap()
                    .as_str()
                    .unwrap()
                    .to_string(),
            ),
            permalink: retrieved_doc
                .get_first(schema.get_field("permalink").unwrap())
                .unwrap()
                .as_str()
                .unwrap()
                .to_string(),
            categories: retrieved_doc
                .get_first(schema.get_field("categories").unwrap())
                .unwrap()
                .as_str()
                .unwrap()
                .split(" ")
                .map(|s| s.to_string())
                .collect(),
            tags: retrieved_doc
                .get_first(schema.get_field("tags").unwrap())
                .unwrap()
                .as_str()
                .unwrap()
                .split(" ")
                .map(|s| s.to_string())
                .collect(),
        };
        results.push((score, entry));
    }
    results
}

Ahora que cubrimos el contenido, continuemos con la parte más interesante: el servidor MCP.

Paso 3 - Construir el servidor MCP

Dado que todos nuestros servicios están construidos en Rust, nos propusimos construir este servicio también en Rust. Afortunadamente, el proyecto MCP tiene una implementación de referencia en Rust para clientes y servidores.

Básicamente seguimos el ejemplo al pie de la letra y conseguimos que un servidor MCP funcionara localmente bastante rápido.

Aquí está el repositorio completo en GitHub para todo el que quiera entrar en todos los detalles.

https://github.com/DownToZero-Cloud/dtz-docs-mcp

Paso 3.5 - complicación inesperada

Así que ahora quisimos desplegar este servidor MCP y rápidamente obtuvimos el error por parte de los clientes LLM de que los servidores MCP remotos solo se admiten a través de TLS. Eso no facilitó nuestro experimento.

Rápidamente adoptamos Let’s Encrypt para generar un certificado TLS al arrancar y usarlo para alojar nuestro MCP. Como ya teníamos código para otras partes de la plataforma DTZ, no necesitábamos demasiados ajustes para esto.

Haremos una publicación extra con la descripción detallada de cómo poner Let's Encrypt en funcionamiento en una configuración de servidor axum.

Let’s Encrypt support for our MCP Server

Reflexiones finales

En conclusión, conseguimos que nuestro servidor MCP funcionara. Está disponible en internet, y lo añadimos a nuestros clientes Cursor, Gemini CLI y ChatGPT. Curiosamente, cada cliente reacciona de manera muy distinta. Cursor simplemente ignora la fuente de información y nunca solicita información adicional, sin importar la tarea. Gemini usa el MCP si lo considera necesario. No está claro cómo o cuándo se invoca, pero usa la fuente de información disponible. ChatGPT no usa el MCP y siempre vuelve a su propia función de búsqueda web, que tiene prioridad sobre el servidor MCP. En el modo Research, ChatGPT usa el MCP, pero los resultados no parecen ser más valiosos que la propia búsqueda web.

Repositorio Github