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_capabilities informa 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 identificadores null com 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:

  1. Ler a próxima linha do stdin, ignorando keepalives vazios.
  2. Decodificar o JSON em Request, emitindo diagnósticos amigáveis em caso de falha.
  3. Comparar o nome do método e montar a carga de resposta.
  4. 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_tools e 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 shutdown para 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.