Como criar um servidor MCP do zero em Python, passo a passo
Um servidor MCP em Python que funciona, de uma pasta vazia até o Claude Desktop. Escreva ferramentas, recursos e prompts com o SDK oficial 2.x, teste-os no Inspector, adicione uma ferramenta real de imagem com chamadas assíncronas à API e publique tudo via Streamable HTTP.
A maioria dos tutoriais de MCP para numa ferramenta de clima que devolve uma string fixa. Este aqui cria um servidor que você realmente pode manter. Você vai escrevê-lo do zero com o SDK oficial de Python, testá-lo sem nenhum cliente de IA, conectá-lo ao Claude Desktop e ao Claude Code, e terminar com uma ferramenta real que chama uma API de imagens e espera o resultado. Tudo abaixo exige Python 3.10 ou mais recente e é compatível com o MCP Python SDK 2.x (2.3.0 no PyPI no momento da escrita, outubro de 2026).
Se você copiou código de um tutorial de 2025 e deu de cara com ModuleNotFoundError: No module named 'mcp.server.fastmcp', está no lugar certo. A classe principal foi renomeada, e a seção de configuração mostra a correção em uma linha.
O que um servidor MCP realmente faz
O Model Context Protocol (MCP) é um padrão para que um aplicativo de IA chame o seu código. Três papéis importam. O host é o aplicativo com que a pessoa conversa, como o Claude Desktop ou uma IDE. O cliente fica dentro do host e fala o protocolo. O servidor é a parte que você constrói. Seu servidor nunca conversa diretamente com o modelo; ele apenas responde às solicitações de um cliente.
Três primitivas, três donos
Um servidor expõe exatamente três tipos de capacidade, e o que os diferencia é quem decide usá-los:
Primitiva
Quem aciona
O que é
Exemplo
Ferramenta
O modelo
Uma função que executa uma ação
Gerar uma imagem, escrever uma linha em um banco de dados
Recurso
A aplicação
Dados carregados no contexto do modelo
Um arquivo, uma configuração, um catálogo
Prompt
O usuário
Um modelo de mensagem reutilizável
Um comando de barra
Se você já construiu uma API web, a correspondência é rápida. Um recurso se comporta como um GET, uma ferramenta se comporta como um POST, e um prompt é uma consulta salva que o usuário executa pelo nome.
💡 Regra prática: se o modelo deve decidir quando executá-lo, faça dele uma ferramenta. Se a aplicação deve anexá-lo, faça dele um recurso. Se uma pessoa deve escolhê-lo em um menu, faça dele um prompt.
Escolha o transporte logo no início
O transporte é a forma como os bytes se movem entre cliente e servidor. Você o escolhe com um argumento para mcp.run().
Transporte
Como funciona
Use para
stdio
O host inicia seu arquivo como subprocesso e se comunica pela entrada e saída padrão dele
Servidores locais, e é o padrão
streamable-http
Um servidor HTTP real em uma porta, com o endpoint em /mcp
Qualquer coisa que você publique
sse
O transporte HTTP mais antigo
Nada novo; foi substituído na revisão do protocolo de 2025-03-26
Comece com stdio. Você vai migrar para Streamable HTTP perto do fim, e o código das ferramentas permanece exatamente o mesmo.
Configure o Python em cinco minutos
Instale o uv e o SDK
Você precisa de Python 3.10 ou mais recente e do uv. Crie um projeto e adicione o SDK:
uv init mcp-image-studio
cd mcp-image-studio
uv add "mcp[cli]" httpx
O extra cli instala o comando mcp com mcp dev, mcp run e mcp install. Um simples pip install "mcp[cli]" também funciona. O Inspector é um aplicativo Node.js, então npx precisa estar no seu PATH.
Uma renomeação que quebra código antigo
No SDK 1.x, a classe de alto nível se chamava FastMCP. Na 2.x, ela é MCPServer, e fica em um módulo diferente:
# SDK 1.x, seen in older tutorials
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Demo")
# SDK 2.x, used in this article
from mcp.server import MCPServer
mcp = MCPServer("Demo")
Outra mudança pega as pessoas de surpresa: configurações de transporte como port saíram do construtor e foram para run(). Passar port= para MCPServer(...) gera um TypeError.
Escreva seu primeiro servidor
Crie server.py. Um arquivo, três decoradores, e todas as primitivas ficam registradas:
from mcp.server import MCPServer
mcp = MCPServer("Demo")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@mcp.resource("greeting://{name}")
def greeting(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@mcp.prompt()
def summarize(text: str) -> str:
"""Summarize a piece of text in one sentence."""
return f"Summarize the following text in one sentence:\n\n{text}"
if __name__ == "__main__":
mcp.run()
Isso já é um servidor funcionando. A proteção if __name__ é importante: mcp dev, mcp run, mcp install e seus testes importam este arquivo, e um run() sem proteção iniciaria um servidor no momento em que qualquer coisa o carregasse.
Adicione uma ferramenta
O SDK lê três coisas da sua função. O nome vira o nome da ferramenta, a docstring vira a descrição que o modelo enxerga, e as dicas de tipo viram o esquema dos argumentos. Você não precisa escrever nenhum JSON Schema, porque a: int, b: inté o esquema. Se um cliente enviar uma string onde você declarou um inteiro, o SDK rejeita a chamada antes de a sua função rodar.
Dê um valor padrão a um parâmetro e ele se torna opcional. Para limites mais rígidos, envolva o tipo em Annotated com um Field do Pydantic:
from typing import Annotated, Literal
from pydantic import Field
@mcp.tool()
def search_books(
query: str,
limit: Annotated[int, Field(ge=1, le=50, description="Maximum results")] = 10,
genre: Literal["fiction", "non-fiction", "poetry"] = "fiction",
) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r} (up to {limit})."
Os limites aparecem no esquema como minimum e maximum, e o Literal se torna um enum do qual o modelo precisa escolher.
Adicione um recurso e um prompt
Um {param} em uma URI de recurso o transforma em um modelo de recurso, então greeting://{name} não tem uma entrada única para listar até que alguém forneça um nome. Um prompt é ainda mais simples: a string que ele devolve vira uma mensagem do usuário. Os dois leem suas descrições da docstring, assim como as ferramentas.
Lance erros que o modelo consiga ler
Quando uma ferramenta falha, lanceToolError. Nunca devolva uma string de erro, porque uma string devolvida tem is_error=False e parece uma resposta bem-sucedida.
from mcp.server.mcpserver.exceptions import ToolError
CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
@mcp.tool()
def get_author(title: str) -> str:
"""Look up the author of a book in the catalog."""
if title not in CATALOG:
raise ToolError(f"No book titled {title!r} in the catalog.")
return CATALOG[title]
O modelo lê essa mensagem, percebe que adivinhou o título errado e chama de novo com um melhor. Um único raise dá a você um agente que se autocorrige. Qualquer outra exceção conta como falha: o modelo só vê que a chamada falhou, e o seu log recebe o traceback.
💡 Declare uma ferramenta async def sempre que ela fizer I/O, como uma chamada de API, uma leitura de arquivo ou uma consulta a banco de dados. Use def simples para todo o resto.
Teste e conecte
Execute o MCP Inspector
Antes de qualquer cliente de IA tocar no seu servidor, execute-o no Inspector:
uv run mcp dev server.py
Abra a URL que ele imprime. O Inspector inicia server.py como subprocesso via stdio, exatamente como um host real faria. Percorra as abas nesta ordem:
Tools:add aparece com um formulário montado a partir das suas dicas de tipo. Chame-a com a=1 e b=2 e você recebe 3.
Resources: a lista está vazia, e greeting aparece em Resource Templates. Informe World e você lê Hello, World!.
Prompts:summarize tem um argumento obrigatório text e devolve uma única mensagem do usuário.
Escreva um teste em memória
A classe Client do SDK também se conecta em memória: entregue a ela o objeto do servidor e não há subprocesso nem porta. Adicione pytest com uv add --dev pytest, depois crie test_server.py:
import pytest
from mcp import Client
from server import mcp
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_add():
async with Client(mcp, raise_exceptions=True) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
Mantenha raise_exceptions=True apenas nos testes. Ele torna visível a mensagem de erro real, em vez da Internal server error higienizada que um chamador remoto veria.
Conecte o Claude Desktop e o Claude Code
Todo host precisa da mesma coisa: o comando que inicia o seu servidor. Este funciona a partir de qualquer diretório, sem ambiente virtual para ativar:
uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
O Claude Desktop é o único host que o SDK consegue configurar para você:
uv run mcp install server.py
Isso grava uma entrada em claude_desktop_config.json, que fica em ~/Library/Application Support/Claude/ no macOS e em %APPDATA%\Claude\ no Windows. Feche o Claude Desktop por completo, não apenas a janela, e depois abra-o de novo. O aplicativo inicia seu servidor com o próprio ambiente, então passe segredos com -v NAME=value ou -f .env.
O Claude Code não precisa de arquivo nenhum. Registre o servidor com a CLI e depois execute /mcp dentro de uma sessão para confirmar que ele está conectado:
claude mcp add image-studio -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
O Cursor lê .cursor/mcp.json no campo mcpServers, e o VS Code lê .vscode/mcp.json em servers com "type": "stdio". O comando dentro dele é idêntico.
Corrija um servidor que não aparece
Primeiro, execute você mesmo o comando de inicialização. Um servidor stdio saudável não imprime nada e espera que um host fale primeiro. Um traceback ou uma saída imediata é o seu bug real. Se ele espera em silêncio, verifique estas três causas:
Sintoma
Causa
Correção
O servidor nunca inicia
Um caminho relativo, porque o host inicia a partir do próprio diretório de trabalho
Use caminhos absolutos, incluindo o de uv (where uv no Windows, which uv nos demais)
As edições não têm efeito
Os hosts leem a configuração na inicialização
Feche o host por completo e abra-o de novo
A conexão cai logo de início
Algo escreveu no stdout, que é o canal do protocolo
Registre logs com o módulo logging, que escreve no stderr, e nunca dependa de print()
O Claude Desktop mantém um log por servidor, chamado mcp-server-<NAME>.log, em ~/Library/Logs/Claude no macOS e em %APPDATA%\Claude\logs no Windows. Esse arquivo é o stderr do seu servidor.
Crie uma ferramenta de imagem real
Um servidor se justifica quando uma ferramenta faz um trabalho que o modelo não consegue fazer sozinho. Esta recebe um prompt, pede à API do PicassoIA uma imagem e devolve a URL.
Desenhe o contrato da ferramenta
A API segue o estilo Replicate: você cria uma predição, consulta o status dela e depois lê o resultado. Estes são os fatos de que a ferramenta depende:
Detalhe
Valor
URL base
https://api.picassoia.com/v1
Autenticação
Authorization: Bearer pia_sk_..., criado na página da API
Criar um job
POST /v1/models/{owner}/{name}/predictions com {"input": {"prompt": "..."}}
5 predições por conta, compartilhadas entre todos os tokens e conexões MCP
A documentação da API afirma que é necessário um plano Infinite, e que uma requisição sem ele retorna 403 plan_required. As predições são descritas como gratuitas e não usam créditos. Verifique seu plano antes de depurar qualquer outra coisa.
Mantenha o contrato enxuto: uma ferramenta, dois parâmetros, uma URL de volta. Todo caminho de falha lança ToolError, então o modelo sempre recebe uma mensagem legível.
Trate jobs assíncronos sem bloquear
Como o job roda em uma GPU remota, a ferramenta precisa esperar sem travar o servidor. Isso significa async def, httpx.AsyncClient e asyncio.sleep:
import asyncio
import os
from typing import Literal
import httpx
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
API = "https://api.picassoia.com/v1"
MODEL = "picassoia/picassoia-image"
DONE = ("succeeded", "failed", "canceled")
mcp = MCPServer("Image Studio")
slots = asyncio.Semaphore(4)
@mcp.tool()
async def generate_image(
prompt: str,
aspect_ratio: Literal["1:1", "16:9", "9:16", "4:3"] = "16:9",
) -> str:
"""Generate an image from a text prompt and return its URL."""
token = os.environ.get("PICASSOIA_API_TOKEN")
if not token:
raise ToolError("PICASSOIA_API_TOKEN is not set for this server.")
headers = {"Authorization": f"Bearer {token}"}
body = {"input": {"prompt": prompt, "aspect_ratio": aspect_ratio}}
async with slots, httpx.AsyncClient(headers=headers, timeout=30) as http:
response = await http.post(f"{API}/models/{MODEL}/predictions", json=body)
prediction = response.json()
if not response.is_success:
raise ToolError(f"{prediction.get('code')}: {prediction.get('detail')}")
while prediction["status"] not in DONE:
eta = prediction.get("eta") or {}
await asyncio.sleep(eta.get("next_poll_in_seconds", 2))
prediction = (await http.get(prediction["urls"]["get"])).json()
if prediction["status"] != "succeeded":
raise ToolError(prediction.get("error") or prediction["status"])
output = prediction["output"]
return output[0] if isinstance(output, list) else output
if __name__ == "__main__":
mcp.run()
Três detalhes tornam isso seguro para deixar rodando:
Consulte no ritmo do servidor. A resposta traz eta.next_poll_in_seconds, então você espera exatamente o tempo que a API pede.
Limite a sua própria concorrência. O Semaphore(4) impede que um modelo muito ativo consuma todas as 5 vagas da conta.
Leia o token do ambiente. Registre-o com mcp install server.py -v PICASSOIA_API_TOKEN=pia_sk_... e nunca o cole no arquivo.
💡 O campo output pode ser uma lista de URLs, uma única URL ou null. As duas últimas linhas tratam os dois primeiros casos, e a verificação succeeded logo acima delas descarta null na prática.
Escrever código de ferramentas é onde um modelo de linguagem economiza mais tempo. O Claude Sonnet 5 no PicassoIA lida com programação em várias etapas e tarefas de uso de ferramentas, então você pode colar a ferramenta generate_image que já funciona e pedir a próxima.
Escreva o prompt. Cole seu servidor e peça: Adicione uma segunda ferramenta que liste minhas predições recentes com GET /v1/predictions. Reutilize o mesmo tratamento de erros. O campo prompt é o único obrigatório.
Defina um prompt de sistema para evitar o problema da renomeação desde o início: Você escreve Python para o MCP SDK 2.x. Importe MCPServer de mcp.server e nunca use FastMCP.
Escolha o esforço. O padrão low pula o pensamento estendido e é o mais rápido. Use high ou max para um bug que toca vários arquivos.
Mantenha o máximo de tokens em 8192 para arquivos de servidor completos, ou reduza para um trecho rápido.
Anexe uma captura de tela de um erro do Inspector, se tiver uma. O modelo lê imagens, e max_image_resolution (padrão de 0,5 megapixel) as reduz.
Gere, copie e teste. Cole o resultado em server.py e rode-o com mcp dev antes de confiar nele.
Parâmetro
Obrigatório
Padrão
O que faz
prompt
Sim
nenhum
Sua solicitação
system_prompt
Não
vazio
Define o papel e as restrições da sessão
effort
Não
low
Profundidade de raciocínio, do mais rápido ao mais profundo
max_tokens
Não
8192
Limite de tamanho da saída
image
Não
nenhum
Uma captura de tela ou diagrama como contexto
Prefere outro modelo? O Kimi K2.6 está na mesma categoria e é descrito como voltado para criar agentes e escrever código.
Publique via HTTP
Troque o transporte
Mude uma linha no fim de server.py:
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=3001)
Os clientes agora se conectam a http://127.0.0.1:3001/mcp. Você também pode deixar o arquivo como está e executar uv run mcp run server.py --transport streamable-http. A chamada run() aceita estas opções:
host e port, com padrões 127.0.0.1 e 8000
streamable_http_path, com padrão /mcp
json_response=True para responder a cada POST com um único corpo JSON
stateless_http=True para um transporte novo a cada requisição
Registre o servidor remoto no Claude Code com claude mcp add --transport http image-studio https://mcp.example.com/mcp.
Proteja o servidor
Quando o seu servidor sai do localhost, três coisas mudam:
A allowlist de Host. O padrão aceita apenas 127.0.0.1, localhost e [::1]. Atrás de um nome de host real, toda requisição falha com 421 Misdirected Request e Invalid Host header. Corrija com transport_security= e liste tanto "mcp.example.com" quanto "mcp.example.com:*" em allowed_hosts.
Autorização. Seu servidor é um resource server do OAuth 2.1. Implemente TokenVerifier com um método assíncrono verify_token que devolve um token de acesso ou None, e passe token_verifier= junto com auth=.
TLS atrás de um proxy. Quando um balanceador de carga encerra o TLS, inicie o uvicorn com --proxy-headers para que ele confie nos cabeçalhos encaminhados.
💡 Um 421 é uma resposta HTTP comum, não um erro de protocolo, então o cliente só mostra uma falha genérica de transporte. O nome de host problemático aparece no log do servidor. Um servidor recém-publicado que recusa todas as conexões é um problema da allowlist de Host até que se prove o contrário.
Crie suas próprias imagens com o Picasso IA
Agora você tem um servidor que registra ferramentas, recursos e prompts, passa em um teste em memória, roda dentro do Claude e pode ser publicado atrás de um nome de host real. A parte que vale a sua próxima hora é a própria ferramenta: troque o slug do modelo, adicione uma ferramenta edit_image ou crie uma ferramenta de vídeo ao lado dela.
Experimente o modelo que o seu servidor acabou de chamar. O PicassoIA Image transforma um prompt em uma imagem pronta em segundos, e você pode testar qualquer prompt no navegador antes de automatizá-lo. Quando uma imagem fixa não basta, o PicassoIA Video e o Seedance 2.5 Lite animam um prompt ou uma foto em clipes curtos.
Abra o Picasso IA, escolha um modelo e rode o mesmo prompt que você daria à sua ferramenta. Depois, conecte-o ao seu servidor e deixe o Claude fazer os cliques.