litemcp
Health Uyari
- License — License: Apache-2.0
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 5 GitHub stars
Code Gecti
- Code scan — Scanned 5 files during light audit, no dangerous patterns found
Permissions Gecti
- Permissions — No dangerous permissions requested
Bu listing icin henuz AI raporu yok.
LiteMCP — the fastest, lightest MCP framework for Rust. Streamable HTTP only (stateless POST/SSE on a bound socket), zero async runtime, serde only.
LiteMCP
La lib MCP la plus légère de l'écosystème Rust : votre serveur MCP en quelques lignes.
Stateless · socket bindé · Streamable HTTP uniquement (POST/SSE) · zéro runtime async
📦 Crate officiel : crates.io/crates/litemcp · 📖 Docs : docs.rs/litemcp
Installation · Démarrage rapide · Tutoriel · Concepts · Configuration · Sécurité · Protocole · FAQ · Client
Pourquoi LiteMCP
La plupart des serveurs MCP sont des services : un programme qui s'installe, charge un gros framework et tourne à côté de votre application. LiteMCP fait l'inverse : c'est une bibliothèque Rust. Vous écrivez quelques lignes, vous appelez .serve(), et un vrai serveur MCP tourne dans votre programme — avec les threads standards du langage, sans runtime async, sans framework HTTP. Seule dépendance pour le JSON : serde.
Pour qui ?
- Une CLI qui veut exposer deux ou trois outils, sans embarquer un framework web ;
- Un test unitaire qui a besoin d'un vrai serveur MCP qui écoute sur un port — sans installer tokio ;
- Un binaire embarqué (edge, IoT), où chaque mégaoctet et chaque dépendance compte ;
- Un agent qui lance cinquante serveurs MCP en même temps : chaque mégaoctet de RAM et chaque demi-seconde de démarrage se multiplie par cinquante.
Les principes :
- Streamable HTTP uniquement. Un seul transport : un socket TCP bindé ; chaque message est un
POST /mcpauto-suffisant, la réponse arrive enapplication/jsonou en flux SSE (text/event-stream, request-scoped). Pas de session, pas d'initializeobligatoire, pas de flux permanent. C'est la spec 2026-07-28, à la lettre. - Zéro runtime async au cœur.
std::net::TcpListener, un thread par connexion. Aucun adaptateur tokio n'est prévu : le périmètre de la lib est volontairement minimal. - Deux dépendances, pas une de plus.
serdeetserde_json— la dérivation JSON Schema et la validation sont faites maison, dans le modulezod. Les featuresmacrosetschemarsen ajoutent d'autres, mais uniquement si vous les activez. - Mesuré ou rien. Chaque release publie taille de binaire, latence et mémoire, avec les conditions de mesure. Jamais d'adjectif sans chiffre.
Le coût réel des features
| Jeu de features | Crates (projet consommateur) | Binaire release (LTO fat + strip) |
|---|---|---|
| défaut (rien d'activé) | 19 | 147 Kio |
+ macros |
20 (+1) | 214 Kio (+67 Kio) |
+ schemars |
70 (+51) | 6,0 Mio (+5,9 Mio) |
Mesuré le 2026-10-24 — Windows 11, rustc 1.99.1, release + lto = "fat" + codegen-units = 1 + strip. Méthode : un projet consommateur externe dépendant de LiteMCP (un serveur, un outil), comparé avec et sans chaque feature — la feature étant réellement utilisée, sinon le linker élimine tout et le binaire ne change pas.
Le cœur est minimal par défaut. Le build par défaut ne tire que deux dépendances : serde et serde_json — rien d'autre.
macrosajoute une crate locale (litemcp-macros, zéro dépendance externe : implémentée uniquement avecproc_macrode la bibliothèque standard — nisyn, niquote, niproc-macro2) ;schemarsest la feature à surveiller : elle tireschemars+jsonschemaet +51 crates transitives (regex,fancy-regex,num,parking_lot,ahash…). Si vous l'utilisez réellement, le binaire passe de 147 Kio à 6,0 Mio ; si vous l'activez sans l'utiliser, le linker élimine tout et le binaire reste à 147 Kio ;- Si vous voulez rester minimal, gardez le jeu par défaut et utilisez le module
zodintégré : il couvre le même terrain — validation, formats, défauts, dictionnaires, énumérations — sans aucune dépendance de plus.
Le cœur du crate reste à 2 dépendances quoi que vous activiez :
schemarsn'est qu'un adaptateur optionnel pour les types#[derive(JsonSchema)].
Fonctions phares
| ⚡ Minuscule | 2 dépendances, binaire minimal 147 Kio (mesuré, release strippé), démarrage en quelques ms |
| 🚀 Sans runtime | threads std, un par connexion — pas de tokio au cœur |
| 🧩 Schémas à la Zod | inputSchema dérivé du schéma, publié dans tools/list ; outputSchema exécuteur (sortie validée avant envoi) |
| ✅ Validation avant exécution | un appel hors schéma ne touche jamais votre handler |
| 🦀 Handlers typés | arguments désérialisés dans vos structs Deserialize ; macro #[tool] (feature macros) qui dérive le schéma des types des paramètres |
| 📚 Resources & Prompts | resources/list·read, prompts/list·get — mêmes indices de cache que tools/list (SEP-2549) |
| ⏱️ Handlers bornés | .tool_timeout(duration) : un handler qui dépasse rend une erreur, la connexion n'est plus retenue indéfiniment |
| 🩺 Erreurs structurées & observabilité | ToolError (rate_limited, timeout, upstream… avec retryable/retryAfterMs) + .on_call(hook) pour métriques et latences |
| 🛑 Arrêt propre | bound.handle() puis handle.shutdown() : serve() rend Ok(()), les connexions ferment entre deux requêtes |
| 🛡️ Sûr par défaut | #![forbid(unsafe_code)], garde DNS-rebinding, corps limité |
Installation
[dependencies]
litemcp = "0.7.0"
# pour la macro #[tool] (deps de compilation seulement) :
# litemcp = { version = "0.7.0", features = ["macros"] }
# pour l'adaptateur schemars (#[derive(JsonSchema)] en schéma exécuteur) :
# litemcp = { version = "0.7.0", features = ["schemars"] }
Ou directement depuis GitHub :
[dependencies]
litemcp = { git = "https://github.com/DeamonDev888/litemcp" }
Démarrage rapide
📖 Envie d'être guidé ? Le tutoriel pas à pas construit un serveur MCP complet avec LiteMCP — du premier outil aux schémas, ressources et prompts.
use litemcp::prelude::*;
fn main() -> std::io::Result<()> {
ServerBuilder::new("calc", "1.0.0")
.tool("add", "Additionne deux nombres", |args| {
let a = args["a"].as_i64().ok_or("a manquant")?;
let b = args["b"].as_i64().ok_or("b manquant")?;
Ok(serde_json::json!({ "somme": a + b }))
})
.bind("127.0.0.1:8080")? // socket bindé, un thread par connexion
.serve() // bloque : POST /mcp → JSON ou SSE
}
C'est tout — votre serveur MCP écoute. Aucune session, aucun handshake, aucun runtime.
Vérifiez-le avec curl :
curl -s http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"a":5,"b":7}}}'
{"jsonrpc":"2.0","id":1,"result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"calc","version":"1.0.0"}},"content":[{"text":"{\"somme\":12}","type":"text"}],"resultType":"complete","structuredContent":{"somme":12}}}
Ou avec MCP Inspector en mode URL : npx @modelcontextprotocol/inspector → http://127.0.0.1:8080/mcp.
Un exemple complet (les trois registres d'outils + annotations) : cargo run --example hello.
Concepts
Outils — schémas à la Zod
On déclare un schéma, l'inputSchema de tools/list en est dérivé, chaque tools/call est validé avant exécution et le handler reçoit des arguments typés. Un appel hors schéma ne touche jamais votre code : il reçoit -32602 avec la liste des problèmes dans error.data.issues.
use litemcp::prelude::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct SearchArgs {
query: String, // requis
limit: Option<u32>, // optionnel
}
ServerBuilder::new("recherche", "1.0.0")
.tool_parse(
"search",
"Recherche indexée",
zod::object()
.field("query", zod::string().min(2).max(64))
.opt("limit", zod::integer().min(1).max(100))
.strict(), // refuse les champs inconnus
|args: SearchArgs| {
Ok(serde_json::json!({ "hits": search(&args.query, args.limit) }))
},
)
.annotate("search", ToolAnnotations::new().title("Recherche").read_only(true))
.bind("127.0.0.1:8080")?
.serve()?;
Trois registres, du plus direct au plus confortable :
| API | Handler reçoit | inputSchema |
Validation |
|---|---|---|---|
tool |
&Value brut |
générique ({"type":"object"}) |
à votre charge |
tool_typed |
&Value (déjà validé) |
dérivé du schéma | automatique |
tool_parse |
struct Deserialize |
dérivé du schéma | automatique |
Schémas disponibles : object().field(..)/.opt(..)/.strict(), string().min/.max et formats exécutés .email()/.uuid()/.url()/.datetime(), integer().min/.max, number().min/.max, boolean(), enumeration(&[..]) (chaînes) et integer_enumeration(&[..]) (entiers), array(s).min/.max, record(valeur) (objet à valeurs typées), plus nullable(schéma) (accepte null, publié anyOf) et describe("…", schéma) (description publiée). Les contraintes deviennent minLength/maxLength/minimum/maximum/minItems/maxItems/enum/format dans le JSON Schema publié à tools/list — appliquées à l'identique par la validation. Des bornes incohérentes (min > max), non finies (NaN/±∞) ou une enumeration vide/doublonnée paniquent au démarrage (erreur de programmation, jamais publiées).
Valeurs par défaut — .default(v) attache une valeur par défaut à n'importe quel schéma. Elle est publiée (mot-clé default du JSON Schema) et appliquée : le champ devient optionnel, et la valeur est injectée dans les arguments avant l'exécution du handler (comme Zod).
zod::object()
.field("query", zod::string())
.opt("limit", zod::integer().default(10)) // publié `default: 10`, injecté si absent
Dictionnaires — record(valeur) valide chaque valeur d'un objet (les clés sont libres) ; publié {"type": "object", "additionalProperties": <schéma>}.
zod::object().field("labels", zod::record(zod::string()))
// {"a": "x"} → valide ; {"a": 1} → refusé (chemin `arguments.labels.a`)
Box<S>, Arc<S> et &S implémentent Schema : un registre de schémas hétérogènes (issus d'une config, d'une base, d'un registre dynamique) s'utilise directement, sans wrapper manuel.
Macros #[tool] · #[resource] · #[prompt] (feature macros)
Le schéma est dérivé des types des paramètres d'une fonction ordinaire — plus de double déclaration schéma/struct. Chaque macro génère register_<fonction>, à enchaîner avec .with_tool(…) :
use litemcp::macros::{prompt, resource, tool}; // features = ["macros"]
#[tool(name = "add", description = "Additionne deux entiers")]
fn add(a: i64, b: i64) -> litemcp::ToolResult {
Ok(serde_json::json!({ "somme": a + b }))
}
#[resource(uri = "mem://doc", name = "Doc", description = "La documentation")]
fn doc() -> Result<litemcp::protocol::ResourceContents, String> {
Ok(litemcp::protocol::ResourceContents {
uri: "mem://doc".into(),
mime_type: Some("text/markdown".into()),
text: include_str!("../README.md").into(),
})
}
#[prompt(description = "Demande une revue de code")] // `name` optionnel (défaut : nom de la fonction)
fn revue(fichier: String, severite: Option<String>) -> Result<Vec<litemcp::protocol::PromptMessage>, String> {
Ok(vec![litemcp::protocol::PromptMessage::user(format!("Relis {fichier}"))])
}
ServerBuilder::new("calc", "1.0.0")
.with_tool(register_add)
.with_tool(register_doc)
.with_tool(register_revue)
.bind("127.0.0.1:8080")?
.serve()?;
#[tool] et #[prompt] valident les arguments via le schéma dérivé (#[prompt] passe par .prompt_typed(…), la variante validée de .prompt()) ; #[resource] prend une fonction sans paramètre (uri requise). Types supportés : entiers, f32/f64, bool, String, Option<T> (champ optionnel), Vec<T>. Le cœur du crate reste à 2 dépendances — et litemcp-macros elle-même n'a aucune dépendance externe (pur proc_macro standard, sans syn ni quote).
Valeurs par défaut — default(nom = valeur, …) déclaré sur la macro (Rust interdit les attribute macros sur les paramètres). Le champ devient optionnel, la valeur est publiée dans l'inputSchema et injectée dans les arguments si elle est absente.
#[tool(description = "Recherche", default(limit = 10, mode = "fast"))]
fn recherche(query: String, limit: u64, mode: String) -> litemcp::ToolResult {
Ok(serde_json::json!({ "query": query, "limit": limit, "mode": mode }))
}
// sans "limit" dans les arguments → limit = 10 ; "limit": 42 → 42
Adaptateur schemars (feature schemars)
Un type #[derive(JsonSchema)] devient un schéma exécuteur — le JSON Schema publié est celui que schemars dérive, et la validation applique le même document via le validateur jsonschema :
use litemcp::schemars::from_schemars; // features = ["schemars"]
use schemars::JsonSchema;
#[derive(JsonSchema)]
struct SearchArgs { query: String, limit: Option<u8> }
ServerBuilder::new("recherche", "1.0.0")
.tool_typed("search", "Recherche indexée", from_schemars::<SearchArgs>(), |args| {
// arguments validés par le schéma dérivé du struct
Ok(serde_json::json!({}))
})
.bind("127.0.0.1:8080")?
.serve()?;
Utilisable partout où l'API attend impl zod::Schema (tool_typed, tool_parse, prompt_typed, output_schema). Sémantiques JSON Schema de bout en bout (le validateur officiel accepte par ex. 3.0 pour integer — pour la garantie as_i64() stricte, restez sur zod::integer()). Les format (email, uuid, date-time, uri…) sont appliqués, comme dans zod. Deux deps de plus, uniquement si la feature est activée.
Schéma JSON brut — pour les schémas que le DSL n'exprime pas (oneOf, not, patternProperties, if/then/else, $ref internes…), from_json_schema prend un document écrit à la main : il est publié tel quel et appliqué par le même validateur.
use litemcp::schemars::from_json_schema;
use serde_json::json;
let schema = from_json_schema(json!({
"type": "object",
"properties": { "value": { "oneOf": [{ "type": "string" }, { "type": "integer" }] } },
"required": ["value"]
}));
ServerBuilder::new("app", "1.0.0")
.tool_typed("t", "t", schema, |args| Ok(args.clone()))
.bind("127.0.0.1:8080")?
.serve()?;
Resources & Prompts
Mêmes registres que les outils, mêmes indices de cache (ttlMs/cacheScope, SEP-2549) :
use litemcp::prelude::*;
ServerBuilder::new("doc", "1.0.0")
.resource("mem://readme", "README", "La documentation du service", || {
Ok(litemcp::protocol::ResourceContents {
uri: "mem://readme".into(),
mime_type: Some("text/markdown".into()),
text: include_str!("../README.md").into(),
})
})
.prompt("revue", "Demande une revue de code", |args| {
let fichier = args.get("fichier").and_then(|v| v.as_str()).unwrap_or("src/main.rs");
Ok(vec![PromptMessage::user(format!("Relis {fichier} et liste les risques."))])
})
.bind("127.0.0.1:8080")?
.serve()?;
resources/read prend {"uri": …} ; prompts/get prend {"name": …, "arguments": {…}} (objet exigé, sinon -32602). Les capacités resources/prompts sont annoncées dans server/discover seulement quand les registres ne sont pas vides.
Annotations
Métadonnées de la spec 2026-07-28, publiées dans tools/list : .title(), .read_only(), .destructive(), .idempotent(), .open_world(). Un client sait ainsi qu'un outil ne modifie rien avant de l'appeler.
Erreurs
- Hors schéma →
-32602avant exécution ;error.data.issuesliste chaque problème en anglais (champ manquant, type, borne, champ inconnu) ; - Erreur d'outil (
ToolError) →CallToolResult::isErroravec le message en texte et un objet machine-lisible sousstructuredContent.error(code,message,retryable,retryAfterMsoptionnel viaToolError::rate_limited/timeout/upstream/invalid_input/unauthorized/not_found/internal). Les erreurs texte (String,&str) restent acceptées via?(From<String>/From<&str>, classéesunclassified) ; - Erreur de protocole (mauvaise requête, outil inconnu, JSON invalide) → erreur JSON-RPC typée (-32700, -32602, -32601…), rendue de façon opaque.
La valeur de retour d'un outil devient structuredContent — un objet JSON passe tel quel ; toute autre valeur (scalaire, tableau, null) est enveloppée dans {"result": …} (choix d'API : structuredContent toujours objet, contrat simple pour les clients) — sauf si un outputSchema de racine non-objet est attaché à l'outil : la valeur brute est alors envoyée telle quelle (la spec MCP 2026-07-28 autorise toute valeur JSON dans structuredContent, et l'enveloppe serait elle-même non conforme au schéma publié). Une Err(msg) devient isError: true avec le message en contenu texte. Un outputSchema attaché via .output_schema("outil", schéma) valide cette sortie avant envoi : non conforme → résultat isError.
Configuration
| Méthode | Défaut | Rôle |
|---|---|---|
.route(path) |
/mcp |
chemin unique du serveur — validé au bind : doit commencer par /, sans query, et ne pas masquer /health (sinon erreur InvalidInput, pas de serveur silencieusement injoignable) |
.allow_origins(&[..]) |
vide | origines Origin autorisées (clients navigateur) — toute requête avec Origin non listée est refusée (garde DNS-rebinding) ; les clients CLI n'en envoient pas. Format : l'origine normalisée navigateur — schéma + hôte en minuscules, sans port par défaut (un navigateur écrit https://a.example, jamais https://a.example:443) ; comparaison exacte insensible à la casse |
.read_timeout(duration) |
30 s | timeout de lecture par opération (anti-slowloris) — un client trop lent est déconnecté sans réponse |
.max_connections(n) |
256 (par serveur) | plafond de connexions simultanées — au-delà, 503 immédiat, pas de spawn |
.connection_ttl(duration) |
5 min | durée de vie maximale d'une connexion keep-alive — borne le temps d'occupation d'un slot (sans elle, un client qui drip-feed des requêtes valides retient son slot indéfiniment) ; 0 désactive le TTL |
.tool_timeout(duration) |
illimité | délai maximal d'exécution des handlers (outils, ressources, prompts) — au-delà : résultat isError (ErrorCode::Timeout, retentable) pour un outil, -32000 pour une ressource/prompt ; les arguments d'un tools/call sont clonés une fois par appel quand il est actif |
.on_call(hook) |
aucun | callback d'observabilité appelé après chaque message JSON-RPC (CallEvent { method, name, duration, status }) ; cumulable, paniques du hook contenues |
Threads de handlers (MAX_INFLIGHT_HANDLERS) |
256 | plafond de threads de handlers simultanés (actifs ou détachés après un timeout de tool_timeout) — au-delà : rate_limited pour un outil, -32000 pour une ressource/prompt ; le slot n'est rendu qu'à la terminaison effective du thread |
Réponse JSON-RPC (MAX_RESPONSE_BYTES) |
16 Mio | une réponse sérialisée qui dépasse ce plafond est remplacée par -32603 (« response size … exceeds maximum … ») — un handler ne peut pas pousser un corps illimité |
bound.handle() → ServerHandle |
— | poignée d'arrêt cloneable : handle.shutdown() rend serve() → Ok(()), les connexions ferment entre deux requêtes (une lecture bloquée en cours ferme au read_timeout) |
Sonde GET /health |
/health (inclus) |
200 « ok » sans JSON-RPC — probes k8s/systemd/équilibreur (HEAD accepté, sans corps) |
| Corps de requête | 1 Mio | au-delà → 413 — lu par tranches (buffer 64 Ko) : la mémoire engagée est proportionnelle aux octets réellement reçus |
| En-têtes | 64 max, lignes 16 Kio max | au-delà → 431 |
Sécurité
Modèle de menace assumé : LiteMCP est une bibliothèque réseau, pas un service exposé. Quiconque peut joindre le socket peut appeler vos outils — il n'y a ni authentification ni TLS intégrés (choix architectural : voir le comparatif ViteMCP, « OAuth 2.1 intégré » est écarté volontairement). Le durcissement intégré couvre le protocole ; la périphérie est à votre charge :
- Liaison : bind
127.0.0.1:8080(comme dans tous les exemples) si le serveur ne parle qu'à des processus locaux ; sinon, exposez derrière un reverse proxy qui apporte TLS, authentification (par ex. OAuth 2.1) et rate-limiting par IP — le plafond de connexions de LiteMCP est global au serveur (256 par défaut) : 256 clients lents peuvent l'occuper et renvoyer503aux autres, c'est à ça que sert le quota par IP du proxy (le TTL de connexion de 5 min borne par ailleurs la durée d'occupation d'un slot) ; - Navigateurs : garde DNS-rebinding intégrée — toute requête portant un
Originnon listé dans.allow_originsest refusée (403), et les préflights CORS échouent par défaut (OPTIONS→405) ; - Handlers bloquants : par défaut il n'y a pas de timeout d'exécution — un handler qui bloque retient son thread et son slot de connexion.
.tool_timeout(duration)borne la réponse (résultatisError/-32000passé le délai) mais ne peut pas tuer le thread du handler (Rust n'a pas d'annulation de thread) : il termine en arrière-plan et son résultat est ignoré. Pour des effets de bord annulables, encadrez vos appels lents vous-même ; - Validation : privilégiez
tool_typed/tool_parse(schéma appliqué avant votre code) ;toolbrut vous laisse la validation des arguments — c'est là que naissent les bugs d'injection côté handler, pas dans le transport. Le schéma publié est exécuteur : ce qu'il annonce est exactement ce que la validation applique.
Le durcissement transport est intégré et vérifié par la suite de tests : limites (corps 1 Mio, 64 en-têtes, lignes 16 Kio, 256 connexions, TTL 5 min), timeouts (lecture 30 s, deadline 60 s — slowloris coupé), anti request-smuggling (Transfer-Encoding ou Content-Length dupliqué → 400 sur toutes les routes, corps consommé partout pour éviter toute désynchronisation de file), parsing strict (ligne d'en-tête sans : ou espace avant : → 400, séparateurs SP uniquement, CRLF strict, version sensible à la casse, Accept/Expect comparés par jeton exact), Host requis, obs-fold refusé, en-têtes à valeur unique dupliqués (Origin, Content-Type, Mcp-Method, Mcp-Name) refusés (400 — le verdict ne doit pas dépendre de l'ordre), Content-Type vérifié (415), paniques de handlers contenues (-32603 avec l'id de la requête, le serveur continue). Le transport a fait l'objet d'un audit complet (statique + dynamique sur serveur réel) ; les correctifs qui en découlent sont consignés dans le CHANGELOG.
Protocole
Spec MCP 2026-07-28 (stateless, sans handshake) — implémentée intégralement :
| Comportement | Réponse |
|---|---|
POST /mcp valide |
200 application/json — réponse JSON-RPC |
POST avec Accept: text/event-stream seul |
200 text/event-stream — un événement data: (SSE request-scoped) + en-têtes anti-tampon |
Notification de protocole (notifications/*, sans id) |
202 Accepted sans corps (acceptée et ignorée) |
Notification d'action (tools/call, resources/read, prompts/get sans id) |
-32600 « notification refusée » — le mode stateless ne peut pas livrer le résultat : le handler ne tourne pas et le client le sait |
server/discover (SEP-2575) |
200 — versions supportées, capacités, serverInfo dans _meta |
Version de protocole dans _meta |
2026-07-28 acceptée · version inconnue : -32022 (UnsupportedProtocolVersionError) · absence tolérée (compatibilité 2025-11-25) |
resultType (SEP-2322) |
présent dans chaque résultat ("complete") |
serverInfo (SEP-2575) |
le serveur s'identifie sous _meta de chaque résultat |
tools/list — ttlMs / cacheScope (SEP-2549) |
60000 / "private" |
outputSchema |
publié dans tools/list et exécuteur : une sortie structuredContent non conforme devient un résultat isError (.output_schema("outil", schéma)) |
resources/list / resources/read |
200 — catalogue et lecture, ttlMs/cacheScope comme tools/list (SEP-2549) ; uri inconnue/manquante : -32602 ; handler en échec : -32000 |
prompts/list / prompts/get |
200 — catalogue et rendu (messages user/assistant) ; nom inconnu : -32602 ; arguments non-objet : -32602 |
Capacités resources / prompts |
annoncées dans server/discover/initialize seulement quand leur registre n'est pas vide |
Mcp-Method / Mcp-Name (SEP-2243) |
acceptés ; incohérents avec le corps → -32020 (HeaderMismatch) |
initialize / ping |
tolérés (méthodes legacy 2025-11-25, retirées de la spec 2026-07-28) |
Sonde GET /health |
200 « ok » (sans JSON-RPC ; probes k8s/systemd) · HEAD /health : mêmes en-têtes, sans corps · un corps annoncé est refusé (400, la sonde n'en accepte aucun) · autre méthode : 405 (Allow: GET, HEAD) |
GET sur /mcp |
405 Method Not Allowed (pas de listener SSE permanent en stateless) |
Version HTTP ≠ HTTP/1.0/HTTP/1.1 exactes (sensible à la casse) · ligne de requête malformée |
505 · 400 |
HTTP/1.1 sans Host (ou Host dupliqué) · ligne obs-fold · en-tête sans : · espace avant : · HTAB dans la ligne de requête · fin de ligne LF seul |
400 |
| Route inconnue | 404 · Content-Length absent (POST) : 411 · malformé : 400 · corps > 1 Mio : 413 |
Content-Type ≠ application/json |
415 Unsupported Media Type (absent : accepté) |
Transfer-Encoding présent (toute route) · Content-Length dupliqué |
400 (anti request-smuggling) |
Content-Type / Mcp-Method / Mcp-Name dupliqués |
400 (en-têtes à valeur unique — un proxy combine ou retient la première valeur : le verdict ne doit pas dépendre de l'ordre) |
En-tête Origin non autorisé |
403 (garde DNS-rebinding ; CLI sans Origin : libre) |
| Toute erreur JSON-RPC | l'id de la requête est repris dans la réponse (JSON-RPC 2.0 §5) |
Benchmarks
Règle du projet : jamais d'adjectif sans chiffre. Toute mesure est
reproductible viacargo run --release --example bench_backendsetcargo run --release --example slo(release, loopback).
Mesures 0.5.1 — Windows 11 (rustc 1.99.1, release LTO fat/strip, loopback)
Trois passes consécutives, même machine, même session :
| Métrique | Valeur | Détail |
|---|---|---|
tools/call keep-alive (moyenne) |
0,077–0,086 ms/req | 2 000 appels, warmup 200 déduit |
| p50 / p99 keep-alive | 0,062–0,065 / 0,297–0,442 ms | 2 000 appels |
| Connexions neuves | 1 576–1 759 conn/s | 300 connexions |
| Binaire release strippé — minimal (1 outil) | 147 Kio (150 528 o) | LTO fat + codegen-units = 1, mesuré depuis un projet consommateur |
Binaire release strippé — démo hello (3 registres + annotations) |
596 Kio (610 816 o) | idem |
| Démarrage in-process (bind + 1ʳᵉ réponse) | 5,4 ms | examples/slo |
| Mémoire après 2 300 requêtes | n/a hors Linux | VmRSS via /proc/self/status |
Le p99 de 0,3–0,44 ms reflète les micro-pics du scheduler Windows en
environnement bureautique ; les campagnes Linux du 2026-10-24 (0,031–0,033 ms
en moyenne, p99 0,048–0,065 ms) restent la référence pour un serveur
dédié — voir la feuille de route pour le détail des conditions figées.
Décisions prises sur mesures
Route maison vs backend alternatif tiny_http (bench A/B du 2026-10-22,examples/bench_backends) :
| Métrique | Route maison | tiny_http | Verdict |
|---|---|---|---|
tools/call keep-alive |
0,10–0,11 ms/req | 0,19–0,20 ms/req | maison ~1,85× plus rapide |
| Connexions neuves | 1 750–2 000 conn/s | 2 080–2 180 conn/s | tiny +10–25 % |
| Coût binaire | — | +360 Ko (+80 %) | maison plus légère |
| Dépendances ajoutées | 0 | +4 (ascii, chunked_transfer, httpdate, log) | maison plus légère |
Décision tranchée par les chiffres : la route maison reste le seul backend — plus rapide en régime keep-alive (le cas d'un agent qui parle à ses serveurs), plus légère en deps et en binaire. Le backend alternatif a été retiré du dépôt : le crate garde exactement deux dépendances (serde, serde_json).
Optimisations « léger & rapide » (A/B dos à dos, même machine et même session) :
| Métrique | 0.3.0 | Après optimisation | Verdict |
|---|---|---|---|
tools/call keep-alive (moyenne) |
0,097–0,111 ms | 0,065–0,070 ms | ~35 % plus rapide |
| p50 / p99 keep-alive | 0,085–0,112 / 0,157–0,166 ms | 0,061 / 0,095–0,123 ms | plus serré |
| Mémoire de lecture par connexion | 8 Ko | 4 Ko | 1 Mio au plafond de 256 connexions |
Les leviers : réponse HTTP assemblée dans un seul buffer (un seul write par réponse — avant : deux syscalls, deux paquets avec nodelay), parsing d'en-têtes zéro allocation (Origin/Host/Accept/Connection réduits à des verdicts, sans chaînes ni Vec), buffers de lecture réutilisés sur toute la connexion keep-alive, zéro clone des arguments dans le dispatch (params déplacé hors du JSON parsé, handler et validation travaillent par référence), route/allow_origins partagés par Arc. Dépendances inchangées : toujours exactement deux (serde, serde_json).
SLO — cibles et campagnes figées
| Métrique | Cible | Linux (Ubuntu 26.04, 2026-10-24) |
|---|---|---|
| Binaire release stripped | < 1,5 Mo | 687 Ko ✅ |
tools/call keep-alive (moyenne) |
< 1 ms | 0,031–0,033 ms ✅ |
| p99 keep-alive | < 2 ms | 0,048–0,065 ms ✅ |
| Connexions neuves | — | 4 400–5 041 conn/s |
| Mémoire après 2 300 requêtes | < 10 Mo | 2,7 Mio VmRSS, +4 Kio pendant la charge ✅ |
| Démarrage in-process | < 10 ms | ~0,4 ms ✅ |
examples/slo mesure lui-même ses bornes : démarrage in-process, 2 300 requêtes keep-alive (warmup déduit) et VmRSS avant/après via /proc/self/status (n/a hors Linux). La suite 196 tests (dont fuzz proptest) est vérifiée sur les deux OS.
Comparatif — ViteMCP (TypeScript)
@vitemcp/server est le framework TS qui cible la même spec stateless 2026-07-28 — le comparatif naturel. Même serveur démo (4 outils identiques) des deux côtés, un seul client de bench neutre (Rust, litemcp-it/examples/bench_remote), Windows 11, loopback, passes alternées (campagne du 2026-10-22).
Résumé express :
| LiteMCP | ViteMCP (TS) | |
|---|---|---|
| ⚡ Temps de réponse moyen | 0,09 ms | 1,43 ms |
| 😰 Le pire cas (p99) | 0,18 ms | 5,4 ms |
| 🔌 Connexions réutilisées | 2000/2000 | 0/2000 |
| 📊 Nouvelles connexions par seconde | ~1000 | ~400 |
| 🧠 Mémoire après 2300 requêtes | 5,5 Mo | ~417 Mo |
Détail complet (plages, passes alternées) :
| Métrique | LiteMCP | @vitemcp/server | Verdict |
|---|---|---|---|
| keep-alive (moyenne) | 0,09–0,10 ms | 1,43–1,73 ms | ~15–18× plus rapide |
| p99 keep-alive | 0,18–0,22 ms | 5,4–5,9 ms | ~25× plus resserré |
| Connexions réutilisées | 2000/2000 | 0/2000 | flux SSE maintenu ouvert côté ViteMCP → reconnect à chaque appel |
| Nouvelles connexions acceptées (connexions/seconde) | 848–1257 connexions/s | 354–498 connexions/s | ~2–3× plus de nouveaux clients par seconde |
| Mémoire après 2 300 requêtes | 5,5 Mo (0,9 Mo privé, reste = DLL partagées) | ~417 Mo (3 processus Node : 47 + 63 + 306) | ~75× moins de mémoire |
L'écart structurel : LiteMCP délimite ses réponses par Content-Length et clôt le flux après l'événement — la connexion TCP reste proprement réutilisable ; ViteMCP répond en SSE encadré qu'il maintient ouvert, le client doit donc rouvrir une connexion par appel séquentiel. (Conditions : tsx/Node 24 côté TS ; les deux serveurs complétaient les mêmes requêtes JSON-RPC.)
Hébergement VPS & proxies (le piège du tamponnage SSE)
Derrière beaucoup d'hébergements, un proxy (nginx, Cloudflare…) met les réponses en tampon et n'envoie les flux SSE que lorsqu'il a accumulé ~8 Ko — les événements « s'empilent ». Deux lignes de défense :
- LiteMCP est structuré pour ça : son SSE est request-scoped — un événement,
Connection: close,Content-Lengthexplicite. Un proxy tamponne les flux en cours ; une réponse terminée est toujours poussée. Le symptôme ne peut pas se produire ; - les réponses SSE portent les en-têtes anti-tampon
X-Accel-Buffering: no(honoré par nginx par réponse, même avecproxy_buffering onglobal) etCache-Control: no-cache, no-transform.
Si vous contrôlez le proxy, la configuration propre :
location /mcp {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_buffering off; # optionnel : X-Accel-Buffering: no suffit
proxy_read_timeout 3600s;
}
Repli officiel si l'hébergeur est hostile au SSE : le client négocie Accept: application/json et reçoit une réponse JSON sèche — aucun flux, rien à tamponner. Les clients MCP gèrent cette négociation automatiquement.
Choix architectural : pas de SSE longue durée. Le mode stateless ne tient aucun flux ouvert (pas de progress ni de notifications serveur→client) : un POST, un événement, clôture. C'est ce choix qui immunise le transport contre le tamponnage des proxies et le rend trivial à déployer derrière nginx. Un flux permanent exigerait des sessions — hors périmètre, réévalué seulement si un besoin concret l'exige.
Tests
cargo test --workspace --all-features # 196 tests : unitaires + intégration (vraies sockets) + doctests
cargo clippy --workspace --all-targets --all-features -- -D warnings # CI stricte
cargo fmt --check # formatage
Journal des versions
L'historique complet et détaillé vit dans CHANGELOG.md — section [Unreleased] en tête : chaque modification y est consignée au fil de l'eau, et une version n'est coupée que pour un lot cohérent (voir la politique de versioning).
Feuille de route
| Phase | Contenu | État |
|---|---|---|
| 0 | Fondations (repo, Cargo, CI, licences) | ✅ |
| 1 | Noyau JSON-RPC + dispatch MCP | ✅ |
| 2 | Transport HTTP stateless (bind, POST/SSE) | ✅ |
| 3 | Durcissement HTTP (timeouts, limites, 100-continue, keep-alive) | ✅ (+ audits complets : Host/505/obs-fold/CL malformé, plafond d'issues ; durcissement 2026-10-23 appliqué ; fuzz + Inspector → 0.4) |
| 4 | Schémas Zod + outils typés + annotations | ✅ (macros → 0.4) |
| 5 | Publication crates.io | ✅ |
| 6 | Conformance officielle + benchmarks → 1.0 | ⏳ |
Le détail — jalons, SLO, risques, journal : docs/ROADMAP.md. Pour apprendre la lib pas à pas : le tutoriel officiel.
FAQ
Par où commencer ?
Par le tutoriel pas à pas — il construit un serveur MCP complet avec LiteMCP, du premier #[tool] aux schémas, ressources et prompts. Le Démarrage rapide ci-dessus suffit pour un premier serveur en dix lignes ; les Concepts détaillent chaque surface ; la doc du crate (docs.rs) référence aussi le tutoriel.
Pourquoi un seul transport ?
Parce qu'un serveur lancé comme sous-processus d'un client meurt avec lui et n'accepte qu'un seul pair — c'est le modèle du client, pas de la lib. LiteMCP fait le choix inverse : votre programme devient un démon Streamable HTTP stateless, bindé sur un socket TCP, que plusieurs agents peuvent joindre, qui survit aux clients et se déploie derrière nginx. Le MCP local en sous-processus est hors périmètre, par conception.
Pourquoi un module de schémas intégré ?
Une lib MCP sans schéma n'est qu'un routeur JSON : les clients ont besoin de l'inputSchema pour présenter vos outils, et vos handlers ont besoin d'arguments garantis. Le module zod couvre les deux — dérivation JSON Schema + validation runtime — sans dépendance de plus : il pèse moins que les bibliothèques de schémas génériques qu'on embarquerait sinon.
Pourquoi pas de tokio ?
Une lib MCP mono-outil n'a pas besoin d'un runtime async complet pour lire des requêtes et répondre du JSON. Le thread par connexion suffit jusqu'à des centaines de connexions (plafond par défaut : 256). Si votre application est déjà dans un runtime async, l'avez-vous simplement dans le même processus — LiteMCP reste un serveur réseau, appelable en HTTP.
Pourquoi réécrire HTTP ?
Le périmètre est volontairement minuscule : une route POST, Content-Length, keep-alive, un writer SSE mono-événement. Pas de chunked entrant, pas de TLS, pas de compression. Ce choix est tranché par les tests : le bench maison contre le backend alternatif testé (tiny_http) montre la route maison ~1,85× plus rapide en keep-alive et 0 dépendance de plus (voir Benchmarks) — et elle embarque son durcissement (timeouts, limites d'en-têtes, Expect: 100-continue).
Pourquoi pas de WebSocket ?
Choix architectural, pas un oubli : WS n'économiserait que le re-parsing des en-têtes HTTP (~1–2 % du trajet — JSON, syscalls et noyau réseau dominent), ne passe pas bien derrière les proxies d'hébergement, et traînerait sessions et reconnexions — les trois choses que le mode stateless évite. La spec 2026-07-28 est HTTP-only ; le keep-alive one-write de LiteMCP est déjà au plancher de TCP.
Pourquoi pas de SSE longue durée ?
Le mode stateless n'a aucun flux ouvert : chaque POST est auto-suffisant (un événement data:, clôture). C'est ce choix qui immunise le transport contre le tamponnage des proxies de VPS et le rend trivial à déployer derrière nginx — voir Hébergement VPS & proxies. Un flux permanent (progress, notifications serveur→client) exigerait des sessions : hors périmètre, réévalué si un besoin concret l'exige.
Comment sont choisies les versions ?
Pacement volontaire : toute modification atterrit d'abord dans la section [Unreleased] du CHANGELOG ; une version n'est coupée que pour un lot cohérent (fonction, correctif, doc majeure). Le 0.x court jusqu'à la conformance officielle MCP verte ; la mention « 1.0 » attend ce jalon.
Client — MCP Command Center
Le MCP Command Center est un client : superviseur
de serveurs MCP en Rust natif (détection multi-machines par scan et SSH, boutique, démarrage auto). Il reconnaît
les serveurs LiteMCP par fingerprint — initialize répondu en 2026-07-28 sans négocier la version demandée
- sonde
GET /health« ok » — affiche alors un badge dédié et supervise vos serveurs (état, outils, latence).
C'est un projet sibling : il consomme LiteMCP, il n'en fait pas partie. La lib n'ajoute aucune dépendance
ni code pour cette intégration — la reconnaissance repose uniquement sur des comportements déjà publics
(initialize, /health).
Licence
MIT OR Apache-2.0 — LICENSE-MIT · LICENSE-APACHE
Yorumlar (0)
Yorum birakmak icin giris yap.
Yorum birakSonuc bulunamadi