Come progetto divertente, abbiamo voluto esporre la nostra documentazione ufficiale — che è anche ospitata qui — come server MCP. L’idea era di testare se questo avrebbe reso più semplice lo sviluppo della nostra piattaforma, dato che i nostri IDE basati su AI avrebbero avuto meno difficoltà a verificare la conoscenza architetturale contro un endpoint pubblico di facile fruizione.
Ora, partendo in modo molto ingenuo, abbiamo ideato il seguente piano per implementarlo.
Come breve anticipazione, ecco cosa vogliamo costruire.
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
Il sito attuale è costruito con Hugo. Quindi tutto il contenuto di questo sito è creato come Markdown e poi renderizzato in HTML. Per i browser, l’HTML è un buon formato. Per i motori di ricerca così come per i LLM, questo sprecherebbe parecchie risorse e avrebbe solo un impatto minimo sulla qualità del risultato. Ma specialmente i LLM sono molto bravi a leggere e comprendere il Markdown. Perciò è diventato chiaro che volevamo fornire Markdown al LLM. Allo stesso tempo, avevamo bisogno di consumare questi dati per il nostro server di ricerca.
Dato che Hugo supporta molteplici formati di output e perfino formati arbitrari, abbiamo iniziato a costruire un output JSON. L’idea era di renderizzare tutte le pagine in un unico grande JSON e vedere cosa ne usciva.
[
{
"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"
},
Ora, per creare qualcosa del genere, Hugo ha bisogno di avere un template pronto. Quindi abbiamo messo il seguente file nella directory dei template di default. 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 -}}
Per ottenere il rendering di questo template, abbiamo dovuto aggiungere l’output JSON nel config.toml.
baseURL = 'https://downtozero.cloud/'
title = 'Down To Zero'
[outputs]
home = ["HTML", "RSS", "JSON"]
Ora che abbiamo creato il JSON, esso diventa disponibile sul sito tramite /index.json.
Recuperare il contenuto più aggiornato è diventato semplice, e questo ha portato alla questione aperta dell’implementazione della ricerca. Poiché stiamo costruendo tutto lo stack su container serverless, con un backend principalmente basato su Rust, la nostra scelta è ricaduta su un container Rust.
Così abbiamo scelto https://github.com/quickwit-oss/tantivy. L’API per questo motore è semplice e non ci preoccupiamo troppo di casi limite o pesi.
Ecco un breve snippet di codice che mostra il recupero e l’indicizzazione.
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
}
Ora che abbiamo coperto il contenuto, continuiamo con la parte più interessante: il server MCP.
Poiché tutti i nostri servizi sono costruiti in Rust, ci siamo prefissati di costruire questo servizio come un servizio Rust. Fortunatamente, il progetto MCP ha una implementazione di riferimento in Rust per client e server.
Abbiamo praticamente seguito l’esempio alla lettera e abbiamo fatto partire un server MCP in locale abbastanza rapidamente.
Ecco il repository GitHub completo per chiunque voglia entrare in tutti i dettagli.
https://github.com/DownToZero-Cloud/dtz-docs-mcp
Ora volevamo distribuire questo server MCP e abbiamo subito ricevuto l’errore dai client LLM che i server MCP remoti sono supportati solo tramite TLS. Questo non ha certo semplificato il nostro esperimento.
Abbiamo quindi adottato rapidamente Let’s Encrypt per generare un certificato TLS all’avvio e usarlo per ospitare il nostro MCP. Dato che avevamo già codice per altre parti della piattaforma DTZ, non abbiamo dovuto fare troppe modifiche.
Faremo un post extra con una descrizione dettagliata su come far funzionare Let's Encrypt in una configurazione di server axum.
Supporto Let’s Encrypt per il nostro server MCP
In conclusione, abbiamo messo in funzione il nostro server MCP. È disponibile su internet e lo abbiamo integrato con i nostri client Cursor, Gemini CLI e ChatGPT. Curiosamente, ogni client reagisce in modo molto diverso. Cursor ignora completamente la fonte d’informazione e non richiede mai informazioni aggiuntive, a prescindere dal compito da svolgere. Gemini usa l’MCP se necessario. Non è chiaro come o quando viene invocato, ma usa la fonte informativa disponibile. ChatGPT non usa l’MCP e ricade sempre sulla sua funzione di ricerca web, che ha priorità sull’MCP server. In modalità Research, ChatGPT usa l’MCP, ma i risultati non sembrano più validi della semplice ricerca web.