Skip to content

Latest commit

 

History

History
205 lines (170 loc) · 6.63 KB

File metadata and controls

205 lines (170 loc) · 6.63 KB

Projet 04 — MCP REST Proxy : wrapper autour d'une API REST

Contexte

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


Objectifs du projet

  1. Implémenter un client HTTP async (reqwest) dans un serveur MCP
  2. Mapper les réponses HTTP (200/404/422/500) en résultats/erreurs MCP
  3. Gérer la pagination cursor-based dans un tool (itère toutes les pages)
  4. Implémenter un tool qui enchaîne plusieurs appels API (workflow)
  5. Tester le serveur MCP sans que l'API soit démarrée (mock HTTP)

Spécifications techniques

Structure du projet

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)

Les 6 tools

list_products

{
  "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." }
    }
  }
}

search_products

{
  "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"]
  }
}

Client HTTP avec retry

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())),
        }
    }
}

Mapping erreurs HTTP → MCP

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,
            },
        }
    }
}

Pagination automatique dans list_products

// 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)
}

Livrables attendus

  • 6 tools fonctionnels qui appellent api-rest-axum
  • list_products avec fetch_all=true itère toutes les pages
  • search_products filtre client-side après pagination complète
  • Toutes les erreurs HTTP correctement mappées en erreurs MCP
  • API_BASE_URL configurable (permet de pointer sur staging/prod)
  • Tests avec mock HTTP (wiremock ou httpmock) — sans API démarrée

Critères de qualité

  • Timeout configurable, jamais de requête bloquante
  • create_product et update_product : validation locale des params avant l'appel HTTP (éviter les allers-retours inutiles)
  • fetch_all limité à 10 000 items max (protection contre les catalogs géants)
  • Logs des appels HTTP vers stderr : méthode, URL, durée, status

Ressources