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 verificar el conocimiento arquitectónico contra un endpoint público fácil de consumir.
Empezando de forma bastante ingenua, se nos ocurrió 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
El sitio web 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 los motores de búsqueda así como para los LLMs, esto malgasta bastantes recursos y solo tiene 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 Markdown al LLM. 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 en JSON. La idea era renderizar todas las páginas que tenemos en un único JSON grande y ver qué salía de ahí.
[
{
"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 pusimos 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"]
Ahora que tenemos el JSON generado, está disponible en el sitio a través de /index.json.
Obtener el contenido más actualizado se hizo fácil, lo que llevó a la cuestión 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 tenemos el contenido cubierto, continuemos con la parte más interesante: el servidor MCP.
Dado que 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 pusimos en marcha un servidor MCP local bastante rápido.
Aquí está el repositorio completo en GitHub para cualquiera que quiera entrar en todos los detalles.
https://github.com/DownToZero-Cloud/dtz-docs-mcp
Entonces, al querer desplegar este servidor MCP, pronto recibimos el error por parte de los clientes LLM de que los servidores MCP remotos solo son compatibles a través de TLS. Eso no facilitó nuestro experimento.
Rápidamente adoptamos Let’s Encrypt para generar un certificado TLS al inicio y usarlo para alojar nuestro MCP. Como ya tenemos código para otras partes de la plataforma DTZ, no necesitábamos demasiados ajustes para esto.
Haremos una publicación extra con una descripción detallada sobre cómo poner en marcha Let's Encrypt en una configuración de servidor axum.
Let’s Encrypt support for our MCP Server
En conclusión, conseguimos 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 ante él. Cursor simplemente ignora la fuente de información y nunca solicita información adicional, independientemente de la tarea. Gemini usa el MCP si lo requiere. No está claro cómo o cuándo se invoca, pero utiliza 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 el modo Research, ChatGPT usa el MCP, pero los resultados no parecen ser más valiosos que la simple búsqueda web.