Servidores Model Context Protocol (MCP) expõem capacidades estruturadas para clientes de IA. Neste guia vamos montar uma implementação mínima em Rust que fala MCP via entrada e saída padrão, encaixando em qualquer cliente compatível sem precisar abrir sockets. Pelo caminho vamos analisar o envelope JSON-RPC usado pelo MCP, mapeá-lo para estruturas tipadas em Rust e ligar handlers suficientes para exercitar o loop com requisições reais.
Pré-requisitos
Você precisa da toolchain estável do Rust (instalada com rustup) e de um cliente MCP recente para testar localmente. O código usa serde e serde_json para lidar com JSON.
Entendendo o Envelope MCP
O MCP usa JSON-RPC 2.0. Cada mensagem enviada pelo cliente contém a versão jsonrpc, um id de correlação, o nome do method e, opcionalmente, um objeto params. As respostas repetem o id e populam um result ou um mapa error. Como o MCP não muda o framing, basta decodificar esse envelope com Serde e focar no roteamento pelo nome do método.
get_capabilitiesinforma ao cliente o que o servidor sabe fazer.- Métodos customizados (por exemplo
call_tool) carregam o trabalho real. - Notificações omitem o
id, então o servidor precisa tratar identificadoresnullcom cuidado.
Conhecer essas regras logo de início evita bugs sutis quando você ampliar a lista de handlers.
Preparando o Projeto
Crie um novo crate binário e adicione as dependências:
cargo new mcp-stdio-server
cd mcp-stdio-server
cargo add serde --features derive
cargo add serde_json
Vamos manter todo o protótipo em src/main.rs.
Estruturas do Protocolo
O servidor precisa responder pelo menos ao método get_capabilities e devolver metadados no envelope JSON-RPC. Defina estruturas simples para desserializar requisições e montar respostas.
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Request {
pub id: serde_json::Value,
pub method: String,
#[serde(default)]
pub params: serde_json::Value,
}
#[derive(Serialize)]
struct Response<T> {
pub jsonrpc: &'static str,
pub id: serde_json::Value,
pub result: T,
}
#[derive(Serialize)]
struct Capabilities {
pub server: ServerInfo,
}
#[derive(Serialize)]
struct ServerInfo {
pub name: &'static str,
pub version: &'static str,
pub description: &'static str,
}
Mantemos os tipos enxutos e derivamos Deserialize ou Serialize direto. Interações reais do MCP incluem dados aninhados (ferramentas, prompts, esquemas), então começar com esse formato torna a extensão mais simples. Repare que Request.id continua sendo serde_json::Value: o MCP aceita números e strings, e preservar o JSON cru evita conversões com perda.
Lidando com Requisições via stdio
Com os tipos prontos, transformamos stdin em um fluxo de frames JSON. O transporte via stdio deixa o binário portátil: pode rodar sozinho ou como processo filho controlado pelo cliente. O loop abaixo destaca quatro responsabilidades principais:
- Ler a próxima linha do stdin, ignorando keepalives vazios.
- Decodificar o JSON em
Request, emitindo diagnósticos amigáveis em caso de falha. - Comparar o nome do método e montar a carga de resposta.
- Serializar a resposta e dar flush em stdout para o cliente não bloquear.
Tratamos entradas malformadas como recuperáveis, registrando o erro em stderr e continuando. Isso deixa sessões longas resilientes durante atualizações do cliente.
use std::io::{self, BufRead, Write};
fn main() -> io::Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
let mut stderr = io::stderr();
for line in stdin.lock().lines() {
let line = match line {
Ok(line) if !line.trim().is_empty() => line,
Ok(_) => continue,
Err(err) => {
writeln!(stderr, "{\"error\":\"stdin read failed: {}\"}", err)?;
continue;
}
};
let request: Request = match serde_json::from_str(&line) {
Ok(req) => req,
Err(err) => {
writeln!(stderr, "{\"error\":\"invalid JSON: {}\"}", err)?;
continue;
}
};
match request.method.as_str() {
"get_capabilities" => {
let response = Response {
jsonrpc: "2.0",
id: request.id,
result: Capabilities {
server: ServerInfo {
name: "rust-mcp-stdio",
version: env!("CARGO_PKG_VERSION"),
description: "Servidor MCP mínimo via stdio",
},
},
};
let serialized = serde_json::to_string(&response)
.expect("serialization must succeed");
writeln!(stdout, "{}", serialized)?;
stdout.flush()?;
}
"ping" => {
let response = Response {
jsonrpc: "2.0",
id: request.id,
result: serde_json::json!({ "ok": true }),
};
let serialized = serde_json::to_string(&response)
.expect("serialization must succeed");
writeln!(stdout, "{}", serialized)?;
stdout.flush()?;
}
unsupported => {
writeln!(stderr, "{\"warn\":\"unsupported method: {}\"}", unsupported)?;
}
}
}
Ok(())
}
Esta implementação assume que cada requisição JSON chega em uma única linha. Clientes MCP normalmente fazem buffer das escritas, então dar flush após cada resposta mantém todos sincronizados.
Expondo a Primeira Ferramenta
Um servidor que só responde capacidades já serve para smoke tests, mas o protocolo ganha vida quando você publica ferramentas. Vamos implementar uma ferramenta simples que coloca qualquer string em maiúsculas. O fluxo é: validar a entrada, aplicar a transformação e espelhar o id na resposta para o cliente correlacionar.
"call_tool" => {
let input = request
.params
.get("payload")
.and_then(|value| value.as_str())
.unwrap_or("");
let response = Response {
jsonrpc: "2.0",
id: request.id,
result: serde_json::json!({
"tool": "uppercase",
"output": input.to_ascii_uppercase(),
}),
};
let serialized = serde_json::to_string(&response)
.expect("serialization must succeed");
writeln!(stdout, "{}", serialized)?;
stdout.flush()?;
}
O unwrap_or("") mantém o exemplo compacto e ainda produz uma resposta válida caso o cliente esqueça de mandar parâmetros. Em produção, prefira devolver um objeto de erro JSON-RPC.
Rodando o Servidor
Compile e rode o binário, então envie uma requisição manual para validar o handshake.
cargo run --quiet <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"get_capabilities"}
EOF
Você deve receber uma resposta JSON com os metadados do servidor:
{"jsonrpc":"2.0","id":1,"result":{"server":{"name":"rust-mcp-stdio","version":"0.1.0","description":"Servidor MCP mínimo via stdio"}}}
Agora exercite a ferramenta:
cargo run --quiet <<'EOF'
{"jsonrpc":"2.0","id":"tool-1","method":"call_tool","params":{"payload":"olá"}}
EOF
A resposta deve repetir o identificador e mostrar o texto transformado:
{"jsonrpc":"2.0","id":"tool-1","result":{"tool":"uppercase","output":"OLÁ"}}
Com o básico funcionando, basta expandir os match arms. Cada handler pode devolver sua própria carga tipada enquanto reutiliza o wrapper Response<T>.
Automatizando um Smoke Test
Vale proteger o loop de transporte com um teste de regressão. Crie tests/smoke.rs, spawn o binário, escreva uma requisição em stdin e faça assertions no JSON de stdout. O exemplo abaixo usa assert_cmd e serde_json para deixar a verificação simples:
use assert_cmd::Command;
use serde_json::Value;
#[test]
fn retorna_capacidades() {
let mut cmd = Command::cargo_bin("mcp-stdio-server").unwrap();
let output = cmd
.write_stdin("{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"get_capabilities\"}\n")
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(json["result"]["server"]["name"], "rust-mcp-stdio");
}
Com um teste assim você garante que refatorações mantêm o contrato intacto.
Próximos Passos
- Anuncie ferramentas e prompts implementando
list_toolse retornando esquemas, permitindo que clientes validem entradas antes de enviá-las. - Troque o loop manual por Tokio ou async-std quando precisar de concorrência, mantendo o framing JSON para preservar o contrato.
- Adicione logging estruturado com tracing para capturar spans por requisição e encaminhá-los ao stderr, facilitando depuração.
- Considere persistir configuração em disco e oferecer um método
shutdownpara encerramento gracioso quando o cliente sair.
Com essa base você pode evoluir para um servidor MCP de produção e, ainda assim, falar o transporte mais simples: a boa e velha entrada/saída padrão. Quando surgir a necessidade, adicione outros transportes, autenticação ou observabilidade sem reescrever o núcleo de protocolo criado aqui.