Kickoff Rust + MCP – #2 – Forger un cerveau d’acier
Aujourd’hui, HAL9000 ouvre les yeux 👀.
On crée le squelette de notre agent IA en Rust, on branche MCP et on le fait respirer pour la toute première fois.

Le moment de vérité : fabriquer le châssis de HAL9000
Lors du dernier article, on a beaucoup parlé de concepts, motivations, IA, sécurité, futur de l’ingénierie; bref de la théorie.
Mais maintenant… il est temps.
Temps d’assembler les premières pièces.
HAL9000, ce sera un agent IA, oui.
Mais pas un agent IA qui est juste « un truc qui parle avec un LLM« .
Ce sera:
- un serveur MCP proprement câblé
- une configuration maîtrisée
- du typage strict
- une architecture nette
- et une base robuste pour des déclinaisons futures.
Juste un châssis en titane qu’on va forger aujourd’hui.
1. Initialisation du projet Rust 🦀
Un petit état des lieux avant tout…

Rust est-il installé ?
Si ce n’est pas le cas, bonne nouvelle : l’installation est ultra simple.
- Installez Rustup (le gestionnaire officiel)
https://rustup.rs - Vérifiez l’installation :
rustc --version
cargo --versionVous devriez obtenir quelque chose du genre :
rustc 1.XX.X (XXXXXXXX 202X-XX-XX)
cargo 2.XX.X (XXXXXXXX 202X-XX-XX) - (Mettez à jour si besoin 🙂
rustup update
Voilà ! Votre machine sait maintenant parler Rust.

La création du projet
Créons notre projet HAL9000 comme ceci :
cargo new hal9000 --bin
Ici, on crée le répertoire hal9000 qui contiendra le code de notre binaire (préciser avec l’option « –bin »).
cd hal9000
Et on s’y positionne !
—
Une fois que ceci est fait, il faut qu’on précise les différentes dépendances de notre projet :
- tokio – async runtime
- rmcp, rmcp-macros – SDK officiel MCP
- serde, serde-json, schemars – sérialisation et validation JSON
- tracing, tracing-subscriber – logs structurés
- clap – parsing CLI
- thiserror, anyhow – gestion propre des erreurs
- reqwest – appel http externe
- chrono – gestion des dates
Ajoutons-les dans le fichier Cargo.toml:
(Ce fichier décrit l’intégralité du projet, son nom, ses dépendances etc…)
...
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
anyhow = "1"
clap = { version = "4", features = ["derive", "env"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "std", "fmt"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
rmcp = { version = "0.8.5", features = [
"server",
"macros",
"transport-sse-server",
"transport-io",
"transport-streamable-http-server",
"auth",
"elicitation",
"schemars",
] }
chrono = "0.4.42"

2. Structure du projet 👷🏼
Voici la structure cible du projet :
src/ ├── main.rs // Point d’entrée, setup ├── config.rs // Lecture config / env vars ├── errors.rs // Gestion d’erreurs structurée ├── server.rs // Serveur MCP + router des tools ├── tools/ // Les compétences de l’agent │ ├── mod.rs │ ├── [tool_name].rs ├── types.rs // Types partagés, schémas JSON └── es/ // Couche d’accès Elasticsearch
L’architecture est pensée pour :
- isolation
- maintenabilité
- extensibilité
- performance
3. 🧱 Les différents fichiers du projet 🧱
Voyons maintenant plus en détail les fichiers composant la première brique de notre agent.
Je ne rentrerai pas dans le détail du code Rust, ce n'est pas le but mais n'hésitez pas si vous avez des questions en terme de syntaxe de code 😉
main.rs: HAL9000 s’allume
C’est la version minimale mais complète de notre point d’entrée :
use clap::Parser;
use rmcp::ServiceExt;
mod config;
mod server;
mod error;
mod types;
/// Main entry point for HAL 9000 MCP server
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Set up logging with tracing
tracing_subscriber::fmt()
.with_env_filter("info")
.with_writer(std::io::stderr)
.init();
tracing::info!("Starting HAL 9000 MCP server...");
// 2. Parse command-line arguments into configuration
let cfg = config::Config::try_parse()
.inspect_err(|e|
error::ServerError::initialization(format!("Failed to parse configuration: {}", e)).traced()
)?;
// 3. Initialize and run the MCP server
let server = server::HAL9000::new(cfg)
.serve(rmcp::transport::stdio())
.await
.inspect_err(|e|
error::ServerError::startup(format!("Failed to start server: {:?}", e)).traced()
)?;
// 4. Wait for the server to shut down gracefully
server.waiting()
.await
.inspect_err(|e|
error::ServerError::runtime(format!("Failed to wait for server shutdown: {:?}", e)).traced()
)?;
tracing::info!("HAL 9000 MCP server has shut down successfully.");
Ok(())
}
Il ne fait « rien » encore…
Mais il écoute, il log, il existe.
C’est le premier battement de cœur de HAL9000 ❤️.
config.rs: maîtriser les paramètres
Rust + Clap = simplicité + élégance dans la définition de notre cli.
use clap::Parser;
use rmcp::schemars;
/// HAL 9000 configuration
#[derive(Parser, Debug, Clone, schemars::JsonSchema)]
#[command(author, version, about, long_about = None)]
pub struct Config {
/// Elasticsearch cluster URL
#[arg(
long,
default_value = "http://localhost:9200",
env = "ES_URL",
hide_env_values = true,
help = "Elasticsearch cluster URL"
)]
pub es_url: String,
/// Elasticsearch API key for authentication
#[arg(
long,
default_value = "",
env = "ES_APIKEY",
hide_env_values = true,
help = "Elasticsearch API key for authentication"
)]
pub es_api_key: String,
}
On obtient :
- une récupération des valeurs par ordre de priorité CLI / env vars / default value
- schéma JSON exposable au LLM si besoin
- cohérence entre humains, machines et agents IA
error.rs
Les erreurs doivent être pensées pour …
- un humain
- un LLM
Donc elles doivent être claires.
use thiserror::Error;
use tracing::{error};
/// Server-specific errors
#[derive(Error, Debug)]
pub enum ServerError {
/// Failed to initialize server
#[error("Failed to initialize server: {reason}")]
Initialization { reason: String },
/// Failed to start server
#[error("Failed to start server on transport: {reason}")]
Startup { reason: String },
/// Server runtime error
#[error("Server runtime error: {reason}")]
Runtime { reason: String },
}
impl ServerError {
/// Create an initialization error with tracing
pub fn initialization>(reason: T) -> Self {
let reason = reason.into();
ServerError::Initialization { reason }
}
/// Create a startup error with tracing
pub fn startup>(reason: T) -> Self {
let reason = reason.into();
ServerError::Startup { reason }
}
/// Create a runtime error
pub fn runtime>(reason: T) -> Self {
let reason = reason.into();
ServerError::Runtime { reason }
}
/// Log the error and return it (for error propagation)
pub fn traced(self) -> () {
match &self {
ServerError::Initialization { reason } => {
error!("Server initialization failed: {}", reason);
}
ServerError::Startup { reason } => {
error!("Server startup failed: {}", reason);
}
ServerError::Runtime { reason } => {
error!("Server runtime error: {}", reason);
}
}
}
}
Message court. Explicite. Actionnable.
Parfait pour une synthèse intelligente.
types.rs
Un exemple simple, le typage des données entrantes et sortantes d’un tool say_hello :
use rmcp::schemars;
use serde::{Serialize, Deserialize};
/// SayHelloRequest represents the input parameters for the say_hello tool.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SayHelloRequest {
#[schemars(description = "Name of the person to greet")]
pub name: String,
}
/// SayHelloResponse represents the output of the say_hello tool.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SayHelloResponse {
#[schemars(description = "Greeting message")]
pub message: String,
}
Ce typage strict offre :
- validation automatique via
schemars - schéma JSON natif MCP
- cohérence Rust <-> IA
server.rs
Ici, notre agent expose ses « skills » et sa « description » (pour que le LLM puisse bien l’appréhender).
use rmcp::{Json, ServerHandler, handler::server::{tool::ToolRouter, wrapper::Parameters}, model::{ServerCapabilities, ServerInfo}, tool, tool_handler, tool_router};
use crate::types::{SayHelloRequest, SayHelloResponse};
#[derive(Debug, Clone)]
pub struct HAL9000 {
/// Server configuration
_cfg: crate::config::Config,
/// Router for handling tool requests
tool_router: ToolRouter,
}
#[tool_router]
impl HAL9000 {
/// Create a new HAL9000 MCP server instance with the given configuration
pub fn new(cfg: crate::config::Config) -> Self {
Self { _cfg: cfg, tool_router: Self::tool_router() }
}
// Here you would define the MCP tool handlers
#[tool(
name = "say_hello",
description = "Tool that responds with a greeting message."
)]
async fn say_hello(&self, Parameters(request): Parameters) -> Result<Json, String> {
tracing::info!("say_hello tool invoked for name: {}", request.name);
Ok(Json(SayHelloResponse {
message: format!("Hello, {}!", request.name),
}))
}
}
#[tool_handler]
impl ServerHandler for HAL9000 {
/// Get server information and capabilities
///
/// Returns metadata about the HAL 9000 server including its capabilities
/// and usage instructions.
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
instructions: Some(
"HAL 9000 - An intelligent security analysis tool for multi-tenant log analysis.\n\
Available tools:\n
- say_hello: Responds with a greeting message
"
.to_string(),
),
..Default::default()
}
}
}
Grâce à #[tool_router], le SDK MCP câble automatiquement le tool annoté en #[tool(...)].
Ici notre tool est « say_hello ».
Un tool simple qui prend en entrée un JSON contenant un field name et qui retourne un JSON contenant, cette fois, le field message.
Grâce à #[tool_handler], on précise les détails, la description, de notre agent.
C’est simple

4. Et maintenant ? 🤔
Il ne nous reste plus qu’à builder et linker le binaire à un outil d’inspection pour voir si tout va bien.
cargo build -r: On va construire un build en mode « release » prêt à l’emploi./target/release/hal9000: On exécute le binaire
Bon, il attend des choses maintenant.
Ajoutons un petit fichier json contenant les messages qu’on lui enverra afin de pouvoir vérifier que tout fonctionne comme attendu :
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test-client", "version": "1.0.0"}}}
{"jsonrpc": "2.0", "method": "notifications/initialized"}
{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "say_hello", "arguments": {"name": "Nicolas"}}}
Ici, les deux premières lignes permettent l’initialisation entre notre serveur et le client stdio.
La troisième ligne contient l’appel réel à notre tool say_hello avec l’argument « Nicolas » dans name.
Pour utiliser ce fichier rien de plus simple :
./target/release/hal9000 < [path du fichier json].jsonoucargo run < [path du fichier json].json
Le serveur HAL9000 doit faire apparaître dans ses logs un petit message avant de s’éteindre :
... [ISODate] INFO serve_inner: hal9000::server: say_hello tool invoked for name: Nicolas ...
Et voilà, HAL9000 vit !
5. Intégration réelle 🌍
Avant de clôturer cet article, voyons l’intégration réelle dans VSCode/Github Copilot et dans Claude Desktop.
VSCode / Github Copilot
Dans votre projet, il faut créer à la racine un dossier .vscode et y créer un fichier mcp.json.
Ce fichier permettra à VSCode de déclarer directement un serveur MCP HAL9000 en utilisant votre projet.
{
"servers": {
"hal9000": {
"type": "stdio",
"command": "cargo",
"args": ["run", "--quiet"],
"cwd": "${workspaceFolder}",
"env": {
"RUST_LOG": "info"
}
}
}
}
Ceci fait, vous pouvez constater l’apparition du HAL9000 dans le mode agent de Github Copilot.
Et ainsi, l’utiliser !

Claude Desktop
Pour Claude Desktop, il vous faudra build votre serveur dans l’architecture de destination sur laquelle s’exécute Claude Desktop.
Si Windows, ajoutez la target x86_64-pc-windows-gnu via la commande : rustup target add x86_64-pc-windows-gnu .
Une fois le binaire adéquat construit, il ne vous reste plus qu’à suivre ce qui est indiqué ici.
Voici la configuration json à utiliser en suivant le tuto :
{
"mcpServers": {
"hal9000": {
"command": "[path du binaire]",
"args": [],
"env": {}
}
}
}
Avec ces intégrations, vous pouvez dès à présent échanger avec le LLM voulu pour lui demander de vous dire bonjour « Nicolas », par ex !
Bienvenue (non pas dans le futur mais) dans le présent ! 🎉
HAL9000 respire … mais n’a encore aucune compétence utile.
C’est un agent qui :
- ✅ existe
- ✅ écoute
- ✅ log
- ✅ mais ne fait rien d’intéressant
🎬 Prochain épisode
Pour le prochain épisode, nous plongerons dans le concret.
On abordera les données, ElasticSearch, les aspects Memory/Skills/Planner, …
Bref c’est là que notre HAL9000 commencera à devenir utile.
Rendez-vous très vite pour :
Les tools « simples » : listing des tenants et health check => le développement d’outils indispensables
PS : retrouvez le repo du projet sur 📁nesimer/hal9000
À propos de l'auteur. Nicolas REMISE est Responsable de l'Usine Digitale Données de DARVA. Passionné par les technos web et IA, il aime partager les nouveautés qu'il met en œuvre au quotidien.
À propos de l'auteur.
Nicolas REMISE est Responsable de
l'Usine Digitale Données de DARVA.
Passionné par les technos web et IA,
il aime partager les nouveautés
qu'il met en œuvre au quotidien.