Building a MCP Server for Service Documentation

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 nuestros IDEs basados en IA tendrían menos dificultad para verificar el conocimiento arquitectónico mediante un endpoint público fácil de consumir.

Ahora, empezando de forma muy ingenua, surgió el siguiente plan para implementar esto.

Como un pequeño adelanto, esto es lo que queremos construir.

flowchart LR
  X[Internet]
  subgraph downtozero.cloud
    A[downtozero.cloud]
  end
  subgraph W[MCP server]
    N[Axum frontend]
    T[tantivy search]
    M[MCP Server]
    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

Step 1 - Export the website

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

Como Hugo soporta múltiples formatos de salida e incluso formatos arbitrarios, comenzamos a construir una salida JSON. La idea era renderizar todas las páginas que tenemos en un solo gran JSON y ver qué resultaba de ello.

[
  {
    "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 preparada. Entonces 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, necesitábamos agregar la salida JSON al config.toml.

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

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

Step 2 - Consume and search

Ahora que tenemos el JSON construido, está disponible en el sitio mediante /index.json.

Obtener el contenido más actualizado se volvió fácil, lo que llevó a la pregunta abierta de cómo implementar la búsqueda. Como estamos construyendo toda nuestra pila en 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 sencilla y no nos preocupamos demasiado por casos borde ni pesos.

Aquí hay un breve fragmento de código que muestra la recuperación e 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 tenemos cubierto el contenido, continuemos con la parte más interesante: el servidor MCP.

Step 3 - Build the MCP server

Como todos nuestros servicios están hechos 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 a rajatabla y logramos poner un servidor MCP funcionando localmente muy rápido.

Aquí está el repositorio completo en GitHub para quien quiera profundizar en todos los detalles.

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

Step 3.5 - unexpected complication

Entonces, ahora quisimos desplegar este servidor MCP y rápidamente recibimos el error de los clientes LLM que los servidores MCP remotos sólo están soportados a través de TLS. Eso no hizo nuestro experimento más fácil.

Adoptamos rápidamente Let’s Encrypt para generar un certificado TLS en el arranque y usarlo para hospedar nuestro MCP. Ya que teníamos código para otras partes de la plataforma DTZ, no necesitábamos muchos ajustes para esto.

Haremos un post extra con una descripción detallada de cómo poner Let's Encrypt corriendo en un servidor axum.

Let’s Encrypt support for our MCP Server

Final thoughts

En conclusión, logramos poner en marcha nuestro servidor MCP. Está disponible en internet, y lo añadimos a nuestros clientes Cursor, Gemini CLI y ChatGPT. Curiosamente, cada cliente tiene reacciones muy diferentes a esto. Cursor simplemente ignora la fuente de información y nunca pide información adicional, independientemente de la tarea. Gemini usa el MCP cuando es 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 recurre a su propia función de búsqueda web, que tiene prioridad sobre el servidor MCP. En modo Investigación, ChatGPT usa el MCP, pero los resultados no parecen ser más valiosos que la simple búsqueda web.

Github Repo