Tu implémentes un serveur MCP qui expose l'API api-rest-axum comme tools MCP.
Au lieu de requêter directement la DB (projet 03), Claude passe par la couche applicative — validation, business logic, pagination cursor-based, erreurs typées.
C'est le pattern le plus courant en production : wrapper une API existante en MCP.
Prérequis : api-rest-axum qui tourne sur http://localhost:3000
Focus : client HTTP async, mapping erreurs HTTP → erreurs MCP, pagination dans les tools
- Implémenter un client HTTP async (
reqwest) dans un serveur MCP - Mapper les réponses HTTP (200/404/422/500) en résultats/erreurs MCP
- Gérer la pagination cursor-based dans un tool (itère toutes les pages)
- Implémenter un tool qui enchaîne plusieurs appels API (workflow)
- Tester le serveur MCP sans que l'API soit démarrée (mock HTTP)
mcp-rest-proxy/
├── Cargo.toml
├── .env ← API_BASE_URL, API_TIMEOUT_SECS
└── src/
├── main.rs
├── server.rs
├── client.rs ← reqwest client + retry logic
├── error.rs ← HttpError → McpError mapping
└── tools/
├── list_products.rs ← GET /products (avec pagination complète)
├── get_product.rs ← GET /products/:id
├── create_product.rs ← POST /products
├── update_product.rs ← PUT /products/:id
├── delete_product.rs ← DELETE /products/:id
└── search_products.rs ← workflow : list + filter (multi-appels)
{
"name": "list_products",
"description": "Lists products from the catalog. Supports pagination. Set fetch_all=true to retrieve all pages automatically (use with caution on large catalogs).",
"inputSchema": {
"properties": {
"limit": { "type": "integer", "description": "Items per page (default 20, max 100)" },
"after": { "type": "string", "format": "uuid", "description": "Cursor for next page" },
"fetch_all": { "type": "boolean", "description": "If true, fetches all pages. Default false." }
}
}
}{
"name": "search_products",
"description": "Searches products by name (case-insensitive substring match). Fetches all pages and filters client-side. Returns matching products with their full details.",
"inputSchema": {
"properties": {
"query": { "type": "string", "description": "Search term" },
"max_results": { "type": "integer", "description": "Stop after this many matches. Default 10." }
},
"required": ["query"]
}
}use reqwest::{Client, StatusCode};
use std::time::Duration;
pub struct ApiClient {
client: Client,
base_url: String,
}
impl ApiClient {
pub fn new(base_url: String, timeout_secs: u64) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.expect("Failed to build HTTP client");
Self { client, base_url }
}
pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
let resp = self.client
.get(format!("{}{}", self.base_url, path))
.send()
.await
.map_err(ApiError::Request)?;
self.handle_response(resp).await
}
async fn handle_response<T: DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T, ApiError> {
match resp.status() {
StatusCode::OK | StatusCode::CREATED => {
resp.json::<T>().await.map_err(ApiError::Deserialization)
}
StatusCode::NOT_FOUND => Err(ApiError::NotFound),
StatusCode::UNPROCESSABLE_ENTITY => {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
Err(ApiError::Validation(body["message"].as_str().unwrap_or("").to_owned()))
}
status => Err(ApiError::Unexpected(status.as_u16())),
}
}
}impl From<ApiError> for rmcp::Error {
fn from(e: ApiError) -> Self {
match e {
ApiError::NotFound => rmcp::Error {
code: ErrorCode::INVALID_PARAMS,
message: "Resource not found".into(),
data: None,
},
ApiError::Validation(msg) => rmcp::Error {
code: ErrorCode::INVALID_PARAMS,
message: msg,
data: None,
},
ApiError::Request(e) => rmcp::Error {
code: ErrorCode::INTERNAL_ERROR,
message: format!("API unreachable: {}", e),
data: None,
},
ApiError::Unexpected(code) => rmcp::Error {
code: ErrorCode::INTERNAL_ERROR,
message: format!("Unexpected HTTP {}", code),
data: None,
},
}
}
}// Si fetch_all=true, enchaîne les appels jusqu'à next_cursor=null
async fn fetch_all_pages(client: &ApiClient) -> Result<Vec<ProductResponse>, ApiError> {
let mut all = Vec::new();
let mut cursor: Option<Uuid> = None;
loop {
let page = client.get_products(cursor, 100).await?;
all.extend(page.data);
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
Ok(all)
}- 6 tools fonctionnels qui appellent
api-rest-axum -
list_productsavecfetch_all=trueitère toutes les pages -
search_productsfiltre client-side après pagination complète - Toutes les erreurs HTTP correctement mappées en erreurs MCP
-
API_BASE_URLconfigurable (permet de pointer sur staging/prod) - Tests avec mock HTTP (
wiremockouhttpmock) — sans API démarrée
- Timeout configurable, jamais de requête bloquante
create_productetupdate_product: validation locale des params avant l'appel HTTP (éviter les allers-retours inutiles)fetch_alllimité à 10 000 items max (protection contre les catalogs géants)- Logs des appels HTTP vers stderr : méthode, URL, durée, status
- reqwest : https://docs.rs/reqwest
- wiremock : https://docs.rs/wiremock
- Spec MCP Tools : https://spec.modelcontextprotocol.io/specification/server/tools