Building a MCP Server for Service Documentation

created: viernes, oct. 10, 2025

Como un 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 contra un endpoint público fácil de consumir.

Ahora, comenzando de manera muy ingenua, ideamos el siguiente plan para implementarlo.

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. Así que 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 así como para LLMs, esto desperdiciaría muchos recursos y solo tendría un impacto mínimo en la calidad del resultado. Pero especialmente los LLMs son muy buenos leyendo y entendiendo Markdown. Así que 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 dentro de un solo JSON grande y ver qué sale de eso.

[
  {
    "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. 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 renderizara, tuvimos que 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 a través de /index.json.

Obtener el contenido más actualizado se volvió sencillo, lo que llevó a la pregunta abierta de implementar una búsqueda. Dado que estamos construyendo toda nuestra pila sobre contenedores serverless, con un backend principalmente en Rust, nuestra elección aquí también cayó sobre 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 los casos extremos o ponderaciones.

Aquí hay un pequeño fragmento de código que muestra 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 construidos en Rust, nos propusimos construir este servicio también en Rust. Por suerte, 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 configuramos un servidor MCP local bastante rápido.

Aquí está el repositorio completo en GitHub para todos los que quieran entrar 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 obtuvimos el error de los clientes LLM indicando que los servidores MCP remotos solo están soportados vía TLS. Eso no facilitó el experimento.

Rápidamente adoptamos Let’s Encrypt para generar un certificado TLS al iniciar y usarlo para alojar nuestro MCP. Como ya contamos con código para otras partes de la plataforma DTZ, no fue necesario hacer demasiados ajustes para esto.

Haremos una publicación adicional con una descripción detallada de cómo hacer funcionar Let's Encrypt en una configuración de servidor axum.

Let’s Encrypt support for our MCP Server

Final thoughts

En conclusión, logramos hacer funcionar nuestro servidor MCP. Está disponible en Internet, y lo añadimos a nuestros clientes Cursor, Gemini CLI y ChatGPT. Curiosamente, cada cliente reacciona muy diferente a ello. Cursor simplemente ignora la fuente de información y nunca solicita información adicional, sin importar la tarea. Gemini utiliza el MCP si es requerido. 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 Research, ChatGPT usa el MCP, pero los resultados no parecen ser más valiosos que solo la búsqueda web.

Github Repo