Come progetto divertente, abbiamo voluto esporre la nostra documentazione ufficiale — che è anche ospitata qui — come server MCP. L’idea era testare se questo avrebbe facilitato lo sviluppo della nostra piattaforma, poiché i nostri IDE basati su IA avrebbero avuto meno difficoltà a verificare la conoscenza architetturale tramite un endpoint pubblico semplice da consumare.
Ora, partendo in modo molto ingenuo, abbiamo ideato il seguente piano per implementarlo.
Come breve introduzione, 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
L’attuale sito è costruito con Hugo. Quindi tutto il contenuto su questo sito è creato in Markdown e poi reso in HTML. Per i browser, l’HTML è un buon formato. Per i motori di ricerca e per i LLM, questo sarebbe uno spreco di risorse e avrebbe solo un impatto minimo sulla qualità del risultato. Ma soprattutto i LLM sono molto bravi a leggere e comprendere il Markdown. Quindi è diventato chiaro che volevamo fornire il Markdown ai LLM. Contemporaneamente, dovevamo consumare questi dati per il nostro server di ricerca.
Poiché Hugo supporta molteplici formati di output e persino formati arbitrari, abbiamo iniziato a costruire un output JSON. L’idea era di rendere tutte le pagine in un singolo 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 un template apposito. Quindi abbiamo inserito 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 far rendere questo template, abbiamo dovuto aggiungere l’output JSON al config.toml.
baseURL = 'https://downtozero.cloud/'
title = 'Down To Zero'
[outputs]
home = ["HTML", "RSS", "JSON"]
Ora che abbiamo costruito il JSON, diventa disponibile sul sito tramite /index.json.
Recuperare i contenuti più aggiornati è diventato facile, il che ha portato alla domanda aperta su come implementare la ricerca. Poiché stiamo costruendo tutto lo stack su container serverless, con un backend principalmente in Rust, la nostra scelta è caduta su un container Rust.
Quindi abbiamo scelto https://github.com/quickwit-oss/tantivy.
L’API per questo motore è semplice e non ci preoccupiamo troppo dei casi limite e dei pesi.
Ecco uno snippet di codice che mostra recupero e 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 i contenuti coperti, continuiamo con la parte più interessante: il server MCP.
Poiché tutti i nostri servizi sono costruiti in Rust, ci siamo posti l’obiettivo di costruire questo servizio come servizio Rust. Fortunatamente, il progetto MCP ha una implementazione di riferimento Rust per client e server.
Abbiamo seguito praticamente l’esempio alla lettera e abbiamo fatto partire un server MCP in locale molto rapidamente.
Ecco il repository GitHub completo per chiunque voglia entrare nei dettagli.
https://github.com/DownToZero-Cloud/dtz-docs-mcp
Ora volevamo deployare 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 facilitato il nostro esperimento.
Abbiamo rapidamente adottato Let’s Encrypt per generare un certificato TLS all’avvio e usarlo per ospitare il nostro MCP. Poiché avevamo già codice per altre parti della piattaforma DTZ, non abbiamo dovuto fare troppi aggiustamenti.
Faremo un post extra con una descrizione dettagliata su come far girare Let's Encrypt in un setup server axum.
Supporto Let’s Encrypt per il nostro MCP Server
In conclusione, siamo riusciti a far funzionare il nostro server MCP. È disponibile su internet, e lo abbiamo aggiunto ai nostri client Cursor, Gemini CLI e ChatGPT. Interessante notare che ogni client ha reazioni molto diverse. Cursor ignora totalmente la fonte informativa e non chiede mai informazioni aggiuntive a prescindere dal compito. Gemini usa l’MCP se necessario. Non è chiaro come o quando venga invocato, ma usa la fonte d’informazione disponibile. ChatGPT non usa l’MCP e torna sempre alla sua funzione di ricerca web, che ha priorità sull’MCP. In modalità Ricerca, ChatGPT usa l’MCP, ma i risultati non sembrano più preziosi della sola ricerca web.