Blog · AI Harness

Como criar um servidor MCP em Python e configurar no Claude Code (claude mcp add, .mcp.json e escopos)

Capiva · Outubro 2026 · 12 min de leitura

Um servidor MCP (Model Context Protocol) é um processo que expõe ferramentas, recursos e prompts num formato padrão, pra qualquer host compatível (Claude Code, Claude Desktop, IDEs) poder usar. O host sobe o servidor, pergunta o que ele oferece e repassa ao modelo. No transporte local, stdio, a conversa é JSON-RPC pelo stdin e stdout do processo. Você escreve funções Python normais; o SDK gera o schema e cuida do protocolo.

Por que isso é um canal de context engineering, e onde RAG entra nessa conversa, está em Harness, RAG e context engineering. Aqui é mão na massa, em duas partes: criar o servidor em Python e configurar no Claude Code (escopos, .mcp.json, aprovação). O exemplo é um servidor que deixa o agente consultar as ADRs (registros de decisão de arquitetura) do projeto antes de propor mudança. É contexto que o agente não tem e que não cabe no CLAUDE.md.

Tudo abaixo usa a v2 do SDK oficial (mcp 2.3.0 no PyPI, Python 3.10+). Se você viu tutoriais com from mcp.server.fastmcp import FastMCP, são da v1. A classe agora é MCPServer.

Instale o SDK

uv init mcp-adr && cd mcp-adr
uv add "mcp[cli]"     # ou: pip install "mcp[cli]"
uv add --dev pytest anyio
mkdir -p docs/adr     # ponha aqui seus arquivos 0001-titulo.md, 0002-...

O extra [cli] traz o comando mcp, que roda o servidor e abre o Inspector.

Decida o que é ferramenta e o que é recurso

O SDK separa as três primitivas por quem decide usar cada uma:

PrimitivaQuem decideNo nosso servidor
ToolO modelobuscar_adrs, ler_adr
ResourceA aplicação (o host)adr://indice
PromptO usuárionenhum

A busca é ferramenta porque o modelo decide quando precisa dela. O índice é recurso: você anexa quando quer dar ao agente o panorama. No Claude Code, recurso entra com menção @, no formato @servidor:protocolo://caminho.

Escreva o servidor

"""Servidor MCP que expõe as ADRs do projeto para o agente consultar."""

import os
import re
from pathlib import Path
from typing import Annotated

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field

RAIZ = Path(os.environ.get("CLAUDE_PROJECT_DIR", Path(__file__).parent))
PASTA_ADR = Path(os.environ.get("ADR_DIR", RAIZ / "docs" / "adr")).resolve()

mcp = MCPServer(
    "adrs",
    instructions="Antes de propor mudança de arquitetura, busque ADRs relacionadas.",
)


class ResumoADR(BaseModel):
    numero: int = Field(description="Número da ADR, ex.: 8 para ADR-0008.")
    titulo: str
    status: str
    trecho: str = Field(description="Linha onde o termo aparece.")


def _adrs() -> dict[int, Path]:
    if not PASTA_ADR.is_dir():
        # Falha alta: pasta errada não pode parecer "nenhum resultado".
        raise ToolError(f"Pasta de ADRs não encontrada: {PASTA_ADR}. Ajuste ADR_DIR.")
    arquivos = {}
    for p in sorted(PASTA_ADR.glob("[0-9][0-9][0-9][0-9]-*.md")):
        arquivos[int(p.name[:4])] = p
    return arquivos


def _titulo_e_status(texto: str) -> tuple[str, str]:
    titulo = texto.splitlines()[0].lstrip("# ").strip()
    m = re.search(r"^## Status\s*\n+\s*(.+)$", texto, flags=re.MULTILINE)
    return titulo, (m.group(1).strip() if m else "desconhecido")


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def buscar_adrs(
    termo: Annotated[
        str, Field(min_length=2, description="Palavra ou trecho a procurar.")
    ],
    limite: Annotated[int, Field(ge=1, le=20)] = 5,
) -> list[ResumoADR]:
    """Procura ADRs (registros de decisão de arquitetura) que mencionam o termo."""
    achados = []
    for numero, caminho in _adrs().items():
        texto = caminho.read_text(encoding="utf-8")
        linha = next(
            (li for li in texto.splitlines() if termo.lower() in li.lower()), None
        )
        if linha:
            titulo, status = _titulo_e_status(texto)
            achados.append(
                ResumoADR(
                    numero=numero,
                    titulo=titulo,
                    status=status,
                    trecho=linha.strip()[:200],
                )
            )
        if len(achados) == limite:
            break
    return achados


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def ler_adr(numero: Annotated[int, Field(ge=1, le=9999)]) -> str:
    """Devolve o texto completo de uma ADR pelo número."""
    caminho = _adrs().get(numero)
    if caminho is None:
        existentes = ", ".join(str(n) for n in _adrs()) or "nenhuma"
        raise ToolError(f"ADR {numero} não existe. Números disponíveis: {existentes}.")
    return caminho.read_text(encoding="utf-8")


@mcp.resource("adr://indice")
def indice() -> str:
    """Lista de todas as ADRs com título e status."""
    linhas = []
    for numero, caminho in _adrs().items():
        titulo, status = _titulo_e_status(caminho.read_text(encoding="utf-8"))
        linhas.append(f"- ADR-{numero:04d} [{status}] {titulo}")
    return "\n".join(linhas)

O que cada decisão faz:

  • Nome, docstring e type hints viram o contrato. O SDK lê o nome da função, a docstring e os tipos e gera o JSON Schema que o cliente recebe em tools/list. A docstring é o que o modelo lê pra decidir se chama a ferramenta. Escreva pensando nele.
  • Annotated[..., Field(...)] vira restrição no schema. limite entre 1 e 20, termo com no mínimo 2 caracteres. Se o modelo manda limite: 99, a chamada falha na validação, antes de rodar sua função.
  • Retorno Pydantic vira saída estruturada. Todo resultado carrega content (texto, pro modelo) e structured_content (dados, pra aplicação).
  • ToolError é o erro que o modelo vê. A mensagem cai no content do resultado e o modelo pode corrigir e tentar de novo, por isso ela lista os números que existem. Uma exceção qualquer (um KeyError, por exemplo) também volta como erro, mas o modelo recebe só Error executing tool ..., sem o texto; o traceback fica no log do servidor. Erro que o modelo pode resolver merece ToolError com mensagem útil.
  • Anotações são dicas, não segurança. read_only_hint=True ajuda o host a decidir se precisa pedir confirmação. A documentação do SDK é direta: nunca dependa de o cliente respeitar.
  • Falha alta quando a configuração está errada. Sem o ToolError em _adrs(), uma pasta inexistente devolveria lista vazia, e o agente concluiria que não existe ADR sobre o assunto. Isso aconteceu comigo nos testes (detalhe no troubleshooting).

Teste em memória com pytest

O Client do SDK aceita o próprio objeto do servidor e conecta em memória, sem subprocesso:

import pytest
from mcp import Client

from server import mcp


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.fixture
async def client():
    async with Client(mcp, raise_exceptions=True) as c:
        yield c


@pytest.mark.anyio
async def test_lista_as_duas_ferramentas(client: Client):
    nomes = {t.name for t in (await client.list_tools()).tools}
    assert nomes == {"buscar_adrs", "ler_adr"}


@pytest.mark.anyio
async def test_busca_devolve_estrutura(client: Client):
    r = await client.call_tool("buscar_adrs", {"termo": "PreToolUse"})
    assert not r.is_error
    itens = r.structured_content["result"]
    assert itens[0]["numero"] == 8
    assert "PreToolUse" in itens[0]["trecho"]


@pytest.mark.anyio
async def test_limite_fora_do_schema_e_rejeitado(client: Client):
    r = await client.call_tool("buscar_adrs", {"termo": "hook", "limite": 99})
    assert r.is_error


@pytest.mark.anyio
async def test_adr_inexistente_vira_erro_legivel(client: Client):
    r = await client.call_tool("ler_adr", {"numero": 42})
    assert r.is_error
    assert "Números disponíveis" in r.content[0].text


@pytest.mark.anyio
async def test_indice_como_resource(client: Client):
    r = await client.read_resource("adr://indice")
    assert "ADR-0011" in r.contents[0].text


@pytest.mark.anyio
async def test_pasta_errada_falha_alto(client: Client, monkeypatch, tmp_path):
    import server

    monkeypatch.setattr(server, "PASTA_ADR", tmp_path / "nao-existe")
    r = await client.call_tool("buscar_adrs", {"termo": "hook"})
    assert r.is_error
    assert "Pasta de ADRs não encontrada" in r.content[0].text
$ uv run pytest -q
......                                                                   [100%]
6 passed in 0.61s

Os testes usam quatro ADRs reais do capivaOS, copiados pra docs/adr. Repare que um ToolError não vira exceção no teste: a chamada retorna normalmente com is_error=True, que é o que o modelo vai receber.

Rode como subprocesso, do jeito que o host roda

O teste em memória não pega problema de transporte. Este script sobe o servidor via stdio:

"""Sobe o servidor como subprocesso (stdio), como um host faria."""

import anyio
from mcp import Client, StdioServerParameters

servidor = StdioServerParameters(
    command="mcp",
    args=["run", "server.py"],
    env={"ADR_DIR": "docs/adr"},  # o subprocesso NÃO herda seu ambiente
)


async def main() -> None:
    async with Client(servidor) as client:
        print("protocolo:", client.protocol_version)
        print("ferramentas:", [t.name for t in (await client.list_tools()).tools])
        r = await client.call_tool("buscar_adrs", {"termo": "fast lane", "limite": 2})
        for adr in r.structured_content["result"]:
            print(f"ADR-{adr['numero']:04d}", adr["status"], "|", adr["titulo"])


anyio.run(main)
protocolo: 2026-07-28
ferramentas: ['buscar_adrs', 'ler_adr']
ADR-0008 Accepted | ADR-0008: Hook-Enforced Phase Guards Parsing sprint-state.md Directly
ADR-0010 Accepted | ADR-0010: Fast-Lane Pipeline for Small, Low-Risk Tasks

Dois detalhes do transporte que derrubam servidor novo:

  • O stdout é o canal do protocolo. Qualquer coisa escrita no stdout antes de o servidor começar a atender (um print no import, um script wrapper que dá echo) corrompe o protocolo. Pra log, use o módulo logging, que escreve no stderr.
  • O subprocesso recebe só uma lista mínima de variáveis (como HOME e PATH), não o seu ambiente. O que o servidor precisa vai em env=.

Pra explorar com interface, uv run mcp dev server.py abre o MCP Inspector, com um formulário por ferramenta gerado a partir dos tipos.

Configure o MCP no Claude Code

claude mcp add --env ADR_DIR=docs/adr --transport stdio adrs -- uv run mcp run server.py
claude mcp list
adrs: uv run mcp run server.py - ✔ Connected

Tudo depois do -- é o comando do servidor, passado intacto. A ordem das opções importa: o --env aceita vários pares CHAVE=valor, e se o nome do servidor vier logo depois dele, o CLI lê o nome como mais um par e falha com Invalid environment variable format. Ponha outra opção no meio, como --transport stdio. Errei isso na primeira tentativa.

Escolha o escopo

EscopoFlagOnde ficaQuem vê
Local (padrão)nenhuma ou --scope local~/.claude.json, sob o caminho do projetoSó você, só nesse projeto
Projeto--scope project.mcp.json na raiz, vai pro gitO time todo, depois de aprovar
Usuário--scope user~/.claude.jsonSó você, em todos os projetos

O "local" do MCP não é o .claude/settings.local.json: a própria documentação avisa que são arquivos diferentes. Se o mesmo nome existe em mais de um escopo, vale um só, na ordem local, projeto, usuário, e os campos não se misturam.

Confira, inspecione e remova

claude mcp get adrs        # escopo, status, comando e variáveis
claude mcp remove adrs -s local

Dentro de uma sessão, /mcp abre o painel com os servidores, o status de cada um e as ferramentas. É ali também que você reativa um servidor desligado só pra aquele projeto.

Compartilhe com o time via .mcp.json

Com --scope project, o Claude Code grava um .mcp.json na raiz pra ir pro git:

{
  "mcpServers": {
    "adrs": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "mcp", "run", "tools/adrs/server.py"],
      "env": {}
    }
  }
}

Cada pessoa precisa aprovar um servidor de .mcp.json antes de ele rodar. Até lá, claude mcp list mostra ⏸ Pending approval. É proteção contra repositório clonado que sobe processo sozinho. E quem sobe o servidor é o Claude Code, que preenche CLAUDE_PROJECT_DIR com a raiz do projeto no ambiente do servidor. Por isso o server.py usa essa variável como padrão.

MCP, API ou Skill?

As três aparecem juntas nas buscas e resolvem problemas diferentes:

  • API é o sistema que você já tem: REST, banco, fila. O agente pode chamar direto (via Bash e curl, por exemplo), mas aí ele precisa descobrir o formato, montar a autenticação e interpretar o erro a cada vez.
  • MCP é um adaptador padronizado em volta dessa API: ferramentas com nome, descrição e schema validado, erro estruturado e credencial guardada no servidor, não no prompt. O mesmo servidor funciona em qualquer host compatível.
  • Skill é conhecimento e procedimento: um SKILL.md com instruções que o Claude carrega quando são relevantes. Não conecta em nada sozinha.

A visão geral de recursos do Claude Code resume assim: "MCP connects Claude to external services. Skills extend what Claude knows, including how to use those services effectively". A combinação que ela sugere é a mais útil na prática: o MCP dá a conexão, e uma skill ensina o jeito do seu time de usar. No nosso caso, o servidor busca ADRs e uma skill poderia dizer quando consultar e como citar a decisão no PR. Como escrever essa skill está em Como criar uma Skill no Claude Code.

Também muda o custo de contexto. Segundo a mesma página, o Claude Code carrega os nomes das ferramentas MCP no início e deixa os schemas completos pra quando forem usados; de uma skill, carrega só a descrição, e o corpo entra quando ela é invocada.

Como usamos na Capiva

Este servidor nasceu da forma como o capivaOS registra decisões: cada mudança de arquitetura vira um ADR numerado, e o argumento de vários deles é "isso não pode depender de o agente lembrar". Expor os ADRs por MCP, em vez de colar tudo no CLAUDE.md, segue a mesma lógica do ADR-0011, que tirou conteúdo da camada sempre carregada: o contexto entra quando o agente precisa, não em toda sessão.

Checklist de troubleshooting

  • [ ] O servidor não conecta. Rode o comando exato do claude mcp list no terminal. Se ele imprime qualquer coisa no stdout ao iniciar, é isso.
  • [ ] A busca volta vazia e não deveria. Confira o diretório que o servidor está lendo. Nos meus testes, um CLAUDE_PROJECT_DIR exportado no shell apontava pra outra pasta e a busca voltava vazia, sem erro. Daí o ToolError de pasta inexistente.
  • [ ] Variável de ambiente "sumiu". O subprocesso não herda seu ambiente. Passe com --env ou env=.
  • [ ] Invalid environment variable format. O nome do servidor veio logo depois do --env.
  • [ ] Servidor de projeto não aparece. Está em ⏸ Pending approval. Rode claude na pasta e aprove.
  • [ ] Demora pra iniciar. O timeout de inicialização se ajusta com a variável MCP_TIMEOUT, em milissegundos.
  • [ ] Resposta grande demais. O Claude Code avisa acima de 10.000 tokens de saída de uma ferramenta MCP e limita em 25.000 por padrão (MAX_MCP_OUTPUT_TOKENS). Pagine ou resuma no servidor. O limite do buscar_adrs existe por isso.
  • [ ] Tutorial antigo não funciona. FastMCP e from mcp.server.fastmcp são da v1. Na v2 é MCPServer.

Perguntas frequentes

Preciso de MCP ou basta um script que o agente roda via Bash?▼

Pra uso pessoal, um script resolve. MCP vale quando você quer schema validado, erro estruturado, descoberta automática e o mesmo servidor funcionando em vários hosts.

Dá pra fazer servidor remoto?▼

Dá. O mesmo servidor roda com mcp run server.py --transport streamable-http, e o Claude Code conecta com --transport http e a URL. Aí entram autenticação e rede, que ficam pra outro post.

Como limito o que o servidor pode fazer?▼

No próprio código: este só lê arquivos de uma pasta. No Claude Code, você pode negar ferramentas MCP específicas nas permissões ou filtrar com um hook de PreToolUse usando o matcher mcp__adrs__.*.

Funciona fora do Claude Code?▼

Sim. MCP é protocolo aberto; qualquer host compatível sobe o mesmo server.py.

Leia também

Precisa conectar agentes aos sistemas internos da sua empresa? Fale com a Capiva.