~/beer-and-code
▪ próximo evento AI Engineering Lab 3ª Edição — Da Arquitetura à Produção · 19—20 Set · 19 e 20 de setembro, das 09h às 13h — ao vivo via Google Meet garantir vaga
~ / tutoriais / como-criar-um-servidor-mcp-do-zero $
Tutoriais

Como criar um servidor MCP do zero: tools, resources e prompts, do stdio ao HTTP

LS Lucas Souza · · 16 min de leitura
Como criar um servidor MCP do zero: tools, resources e prompts, do stdio ao HTTP

Entender o que é MCP é uma coisa. Subir o seu primeiro servidor e ver o agente usá-lo é outra completamente diferente.

Um servidor MCP é o que transforma o seu código numa ferramenta que o Claude (ou qualquer agente compatível) sabe chamar sozinho. Em vez de colar dado no prompt na mão, você expõe uma função, o modelo decide quando usar, chama, lê o retorno e segue o raciocínio. É a diferença entre o agente "saber" e o agente "fazer".

Neste tutorial a gente constrói um servidor MCP do zero em Python, com os três tipos de capability que o protocolo define — tool, resource e prompt — e roda ele de dois jeitos: via stdio (pro Claude Desktop no seu laptop) e via HTTP (pra quando esse server precisa virar serviço de verdade). No fim você pluga no Claude e vê o agente chamar uma função que você mesmo escreveu.

TL;DR

  • O que é: um servidor MCP em Python que expõe uma tool, um resource e um prompt, rodando em stdio e HTTP.
  • Stack: Python 3.10+, SDK oficial mcp (FastMCP), uv pra gerenciar o ambiente.
  • Custo/Acesso: zero. SDK open-source, roda local, sem chave de API pra subir o server.
  • Pré-leitura: se "MCP" ainda soa abstrato, comece por O que é MCP. Aqui a gente parte do princípio que você já sabe o que o protocolo resolve.

O contexto: por que escrever seu próprio servidor MCP?

O Model Context Protocol é o padrão aberto que a Anthropic lançou em novembro de 2024 pra resolver um problema bobo e caro ao mesmo tempo: cada integração entre um modelo e uma ferramenta era reescrita do zero, de um jeito diferente, em cada produto. MCP padroniza esse contrato. Você escreve a integração uma vez, e qualquer host que fala MCP — Claude Desktop, Claude Code, e dezenas de outros clientes — consegue usar.

Tem montanha de servidor MCP pronto por aí. Então por que escrever o seu?

Porque o servidor pronto não conhece o seu domínio. Ele não sabe consultar o seu banco, ele não tem a regra de negócio da sua empresa, ele não expõe o endpoint interno que só existe na sua VPC. No momento que você quer dar ao agente acesso a algo que é seu, não tem atalho: você escreve um servidor MCP. E a boa notícia é que isso é muito mais simples do que parece.

Um servidor MCP pode oferecer três coisas, e dá pra entender cada uma por quem controla a ação:

  • Tool — uma função que o modelo decide chamar. "Consulta esse CEP", "roda essa query", "cria esse ticket". É o tipo mais usado.
  • Resource — um pedaço de dado que o cliente/aplicação decide carregar pro contexto. Tipo um arquivo: os padrões de código do time, um catálogo, a doc de uma API.
  • Prompt — um template reutilizável que o usuário dispara. Aparece como um comando pronto no host ("revisar PR", "gerar changelog").

A regra de quem controla cada um vem direto da doc oficial de conceitos de servidor. Guarda isso, porque é o que evita o erro clássico de transformar tudo em tool.

Pré-requisitos e ferramentas

Checklist curto antes de começar:

  • [ ] Python 3.10 ou superior
  • [ ] uv instalado (o gerenciador de pacotes da Astral — mais rápido que pip e resolve o ambiente sozinho)
  • [ ] Noção básica de Python com type hints (o SDK usa as anotações de tipo pra gerar o schema das tools)
  • [ ] Claude Desktop instalado, se você quiser ver o server rodando num host de verdade no fim

Cria o projeto e instala o SDK:

uv init servidor-mcp
cd servidor-mcp
uv add "mcp[cli]"

O extra [cli] traz o utilitário de linha de comando (mcp dev, mcp install) que a gente usa pra testar e plugar. Se você é time pip, é pip install "mcp[cli]" num virtualenv — o resto do tutorial é idêntico.

Mão na massa: o servidor passo a passo

A gente vai construir um servidor que dá ao agente acesso a um "manual do time" fictício: uma tool que calcula prazo de entrega, um resource que expõe os padrões de código, e um prompt pronto de code review. Cria um arquivo server.py.

Passo 1: o servidor e a primeira tool

O SDK oficial tem uma API de alto nível chamada FastMCP. Você instancia o servidor com um nome e decora funções. As anotações de tipo viram o JSON Schema dos argumentos, e a docstring vira a descrição que o modelo lê pra decidir quando chamar. É por isso que docstring de tool MCP não é firula — é a interface entre o seu código e o raciocínio do agente.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("manual-do-time")

@mcp.tool()
def prazo_entrega(distancia_km: float, expresso: bool = False) -> str:
    """Calcula o prazo de entrega em dias úteis para uma distância em km.

    Use quando o usuário perguntar quanto tempo leva uma entrega.
    """
    base = 1 + distancia_km / 400
    dias = base * (0.5 if expresso else 1.0)
    return f"Prazo estimado: {round(dias, 1)} dias úteis"

Repara em duas coisas. Primeiro: a docstring fala quando usar a tool, não só o que ela faz — isso melhora a decisão do modelo. Segundo: o expresso: bool = False com valor default vira um parâmetro opcional no schema automaticamente. Você não escreveu uma linha de JSON Schema, e o agente já sabe que pode passar expresso=true.

Passo 2: um resource pra expor dado, não ação

Tool é pra ação. Quando você só quer disponibilizar um pedaço de informação pro contexto — sem que o modelo precise "executar" nada — o tipo certo é resource. Resource tem uma URI, e o cliente decide carregar.

@mcp.resource("padroes://codigo")
def padroes_de_codigo() -> str:
    """Os padrões de código que o time segue."""
    return (
        "1. PHP: PSR-12, tipagem estrita em todo arquivo.\n"
        "2. Commits no padrão Conventional Commits.\n"
        "3. Toda feature nova precisa de teste de feature.\n"
        "4. Nada de lógica de negócio no controller."
    )

Dá pra usar template na URI também, com chaves: @mcp.resource("padroes://{linguagem}") recebe linguagem como argumento e te deixa servir conteúdo dinâmico por chave. O ponto mental: resource é leitura, tool é efeito. Misturar os dois é o erro que mais aparece em server iniciante — joga tudo em tool e o agente fica chamando função pra ler dado estático que devia ser resource.

Passo 3: um prompt reutilizável

O terceiro tipo é o prompt: um template que o usuário dispara no host, normalmente como um comando. Bom pra encapsular aquele prompt que todo mundo no time cola na mão toda vez.

@mcp.prompt()
def revisar_pr(diff: str, foco: str = "bugs e segurança") -> str:
    """Gera um prompt de code review a partir de um diff."""
    return (
        f"Revise o diff abaixo com foco em {foco}. "
        f"Seja direto, aponte o problema e a linha, sugira o fix.\n\n"
        f"```diff\n{diff}\n```"
    )

Agora o servidor tem as três capabilities. Falta rodar.

Passo 4: rodar em stdio e plugar no Claude Desktop

O transporte mais simples é o stdio. Funciona assim: o cliente sobe o seu servidor como um subprocesso e conversa via stdin/stdout, uma mensagem JSON-RPC por linha. É o transporte padrão pra rodar local, e o que o Claude Desktop usa.

Adiciona o bloco de execução no fim do server.py:

if __name__ == "__main__":
    mcp.run()

mcp.run() sem argumento já sobe em stdio. Uma regra de ouro do stdio que pega muita gente: nunca escreva nada que não seja mensagem MCP no stdout. Um print() de debug perdido corrompe o protocolo e o cliente desconecta. Log de debug vai pro stderr — o stdout é sagrado.

Antes de plugar em qualquer lugar, testa local com o MCP Inspector, que sobe uma UI web pra você chamar as tools na mão:

uv run mcp dev server.py

Com o server funcionando, instala no Claude Desktop com um comando:

uv run mcp install server.py

Isso registra o servidor no claude_desktop_config.json (o arquivo de config dos MCP servers do Claude Desktop). Reinicia o Claude, e a sua tool aparece. Pergunta "quanto tempo leva uma entrega de 800km no modo expresso?" e você vê o agente chamar prazo_entrega — uma função que você escreveu há cinco minutos.

Passo 5: trocar pra HTTP quando o server vira serviço

stdio é ótimo pro laptop. Mas subprocesso local não escala: você não vai subir um subprocesso por usuário num servidor corporativo. Pra isso existe o Streamable HTTP, o transporte que substituiu o antigo HTTP+SSE (deprecado desde a revisão 2024-11-05 do protocolo). Ele expõe um endpoint HTTP único, que aceita POST e GET, e pode usar Server-Sent Events pra streamar respostas.

A mudança no código é de uma linha — o mesmo servidor, outro transporte:

if __name__ == "__main__":
    mcp.settings.host = "127.0.0.1"
    mcp.settings.port = 8000
    mcp.run(transport="streamable-http")

Sobe em http://localhost:8000/mcp. Esse /mcp é o endpoint MCP — o caminho único que o cliente usa pra falar com o server. Agora qualquer host que aponta pra essa URL conversa com o seu servidor por rede, sem subprocesso nenhum.

E aqui já entra o primeiro alerta de segurança, que a própria spec crava: ao servir por HTTP, o servidor deve validar o header Origin em toda conexão pra evitar ataque de DNS rebinding, e rodando local deve fazer bind só em 127.0.0.1, nunca em 0.0.0.0. Endpoint MCP aberto na rede sem isso é porta destrancada.

Limitações e pontos de atenção

O server que você acabou de subir roda. Mas "roda no meu laptop" e "roda em produção" são dois universos.

  • Autenticação. stdio não pede auth porque o cliente é dono do processo. No instante que você expõe por HTTP, autenticação deixa de ser opcional — a spec exige. O caminho recomendado é OAuth 2.1 com PKCE, schema validado e um gateway no meio, e a gente destrinchou exatamente isso num post separado.
  • stdout é do protocolo. Vale repetir porque é o bug número um do stdio: qualquer biblioteca que faz print ou loga no stdout vai quebrar o canal. Isola o log no stderr.
  • Prompt injection via tool. Se a sua tool retorna texto que veio de fonte externa (um site, um e-mail, um campo de usuário), esse texto entra no contexto do modelo. Trate retorno de tool como entrada não confiável.
  • Header de versão. Em HTTP, o cliente manda o header MCP-Protocol-Version (a revisão atual é 2025-06-18). Se o seu server ignora isso e a spec evolui, você quebra na cara dos clientes novos.

Esse bloco não é pra assustar. É pra deixar claro que o tutorial te leva do zero ao servidor funcional — e que de funcional pra produção tem uma segunda etapa de engenharia que não dá pra pular.

▪ Clã Beer and Code

Tutorial te mostra o caminho — no Clã você constrói junto. Aula ao vivo toda semana, projetos reais de Engenharia de IA, ao lado de quem já está em produção.

Entrar no Clã

FAQ rápido

Preciso de chave de API pra rodar um servidor MCP? Não. O servidor é o seu código local — não custa nada subir e testar. Chave de API só entra se dentro de uma tool você chamar um serviço pago (um modelo, uma API externa). O protocolo em si é gratuito e open-source.

Posso escrever em outra linguagem além de Python? Sim. Existe SDK oficial em TypeScript, Python, Go, Kotlin, Java e C#. A escolha aqui foi Python por causa do FastMCP, que é a forma mais enxuta de sair do zero. O protocolo é o mesmo independente da linguagem.

Stdio ou HTTP — qual eu uso? stdio pra ferramenta local, pessoal, que roda na máquina do dev (Claude Desktop, Claude Code). HTTP quando o server precisa servir múltiplos clientes pela rede, ou virar um serviço com deploy próprio. Mesmo código, transporte diferente.

Como o agente sabe quando chamar minha tool? Pelo nome, pelos type hints e principalmente pela docstring. O modelo lê a descrição de cada tool e decide com base na intenção do usuário. Docstring vaga = tool subutilizada. Trate a docstring como parte da engenharia, não como comentário.

Tool ou resource: qual usar?

Com as três capabilities na mão, a decisão que realmente importa é essa: quando algo vira tool e quando vira resource. O teste é uma pergunta só — quem precisa decidir que aquilo acontece? Se é o modelo, no meio do raciocínio, escolhendo agir (bater numa API, gravar, abrir chamado), é tool. Como tool tem efeito, ela costuma passar pela aprovação do usuário antes de rodar, e isso é desejável.

Se o dado já existe, não muda a cada turno e serve de pano de fundo — schema do banco, doc do projeto, convenções do time — é resource. Quem injeta é a aplicação, sem efeito colateral, então pode ser lido quantas vezes for preciso sem risco. O anti-padrão mais comum em server iniciante é empacotar leitura estática como tool: o agente gasta turno chamando função pra receber um texto que já podia estar no contexto desde o início. No exemplo complementar com a ViaCEP a gente mostra as duas metades lado a lado, uma tool que age e um resource que só descreve.

Tool async com I/O real: consultando a ViaCEP com httpx

A prazo_entrega do Passo 1 é síncrona porque só faz conta. Na vida real, a maioria das tools faz I/O — HTTP, banco, fila — e aí o caminho natural no FastMCP é async def. O decorator aceita corrotina sem configuração extra; você só precisa de um cliente async por baixo. Um exemplo enxuto com httpx e a ViaCEP, que não exige chave:

import httpx

@mcp.tool()
async def consultar_cep(cep: str) -> str:
    """Busca o endereço de um CEP brasileiro (8 dígitos, com ou sem traço)."""
    cep = cep.replace("-", "").strip()
    async with httpx.AsyncClient(timeout=10.0) as client:
        resp = await client.get(f"https://viacep.com.br/ws/{cep}/json/")
    dados = resp.json()
    if resp.status_code != 200 or dados.get("erro"):
        return f"Não encontrei o CEP {cep}."
    return f"{dados['logradouro']}, {dados['bairro']} - {dados['localidade']}/{dados['uf']}"

Três detalhes que fazem diferença: timeout explícito (tool pendurada trava o turno do agente), retorno em texto legível em vez de JSON cru (o modelo vai ler aquilo), e mensagem de erro que diz o que aconteceu em vez de estourar exceção. Lembra de adicionar a dependência com uv add httpx. A versão completa dessa tool, com o passo a passo de teste no Inspector, está no post do primeiro MCP server.

Resource template: parâmetro na URI com padroes://{area}

No Passo 2 a gente citou de passagem que a URI de um resource aceita chaves. Isso se chama resource template, e é o que transforma um resource fixo num catálogo dinâmico: em vez de registrar padroes://commits, padroes://testes e padroes://branches um a um, você declara um único padrão de URI e a função recebe o segmento variável como argumento.

PADROES = {
    "commits": "Conventional Commits, mensagem no imperativo.",
    "testes": "PR sem teste não entra. Pest pra unit.",
    "branches": "feature/<ticket>-descricao, nunca push na main.",
}

@mcp.resource("padroes://{area}")
def padrao_do_time(area: str) -> str:
    """Padrão de engenharia do time para uma área."""
    return PADROES.get(area, f"Nenhum padrão definido para '{area}'.")

O cliente pede padroes://testes, o FastMCP casa com o template, chama a função com area="testes" e devolve o texto. Repara que continua sendo leitura pura — a função não muda estado, só resolve uma chave. É o mesmo mecanismo que a doc oficial usa em exemplos como weather://forecast/{cidade}, e o tutorial complementar constrói exatamente esse resource junto com a tool de CEP.

Plugando na mão: o claude_desktop_config.json (e o caso do Linux)

O uv run mcp install server.py do Passo 4 é o atalho. Mas vale saber o que ele escreve, porque cedo ou tarde você vai editar esse arquivo na mão — pra apontar pra outro diretório, trocar o comando, ou registrar um server que não foi instalado pelo mcp CLI. Cada entrada em mcpServers é um comando com argumentos, e o jeito mais robusto de rodar um projeto uv é passar --directory com o caminho absoluto da pasta (o pwd te dá):

{
  "mcpServers": {
    "manual-do-time": {
      "command": "uv",
      "args": ["--directory", "/caminho/absoluto/servidor-mcp", "run", "server.py"]
    }
  }
}

Caminho relativo aqui é fonte clássica de "o server não aparece": o Claude Desktop não roda a partir da sua pasta, então o uv não acha o projeto. Salvou, reiniciou o Claude, a tool aparece.

Uma ressalva pra quem desenvolve em Linux: o Claude for Desktop não tem build oficial pra esse SO. Nesse caso o MCP Inspector (uv run mcp dev) continua sendo o seu ambiente de teste, e pra usar num host de verdade o caminho é o Claude Code ou qualquer outro cliente MCP que rode no Linux — a config segue o mesmo formato de comando + args. O post do primeiro MCP server mostra esse snippet aplicado ao exemplo da ViaCEP.

Conclusão

Você saiu de um arquivo vazio pra um servidor MCP com tool, resource e prompt, rodando em stdio e em HTTP, plugado no Claude. O conceito que parecia abstrato virou função chamada pelo agente na sua frente. E o pulo mental mais importante ficou claro: tool é ação que o modelo dispara, resource é dado que o cliente carrega, prompt é template que o usuário aciona. Errar isso é o que separa um server amador de um bem desenhado.

O próximo degrau é pegar esse server e levar pra valer: autenticação, schema estrito, observabilidade, gateway. É a fronteira entre criar seu primeiro MCP server e fazer ele aguentar produção — e é exatamente esse salto, do prompt isolado até o harness completo de um agente rodando de verdade, que a gente vai construir ao vivo, mão na massa, no workshop Do Prompt ao Harness: construindo um agente de vendas. Servidor MCP é a peça que entrega ferramentas pro agente — saber montar o harness em volta dele é o que faz o produto existir.

Lucas Souza
Escrito por
Lucas Souza

{AI Engineer} — apaixonado por Laravel, arquitetura de software e construir produtos com impacto. Compartilho aqui tutoriais, descobertas e reflexões sobre o dia a dia de engenharia.

▪ Clã Beer and Code

Conteúdo é o que não falta. Falta quem desembaralhe: o que importa agora é como implementar do jeito certo. No Clã você tem isso ao vivo, toda semana, com quem já filtrou o ruído.

Entrar no Clã
Conheça o Clã Beer and Code
tocando