Pular para o conteúdo

O servidor que roda na sua máquina

As três Tools estão prontas e funcionam. Só que elas só funcionam para um agente: o que está escrito no mesmo arquivo que elas.

Se amanhã você quiser um segundo agente — um para relatórios, outro para consulta rápida —, copia o arquivo. Se um colega quiser as mesmas Tools, copia o arquivo. Se você corrigir um defeito (e a Unidade 2 mostrou que se corrige), corrige em cada cópia, ou convive com versões diferentes da mesma consulta rodando em máquinas diferentes.

Este capítulo tira as Tools do script. Ao final, elas vão estar rodando como um servidor MCP na sua máquina, e qualquer agente seu — hoje ou daqui a um ano — passa a enxergá-las sem tê-las dentro.

O que o MCP é, em uma frase

MCP (Model Context Protocol) é a mesma função, do outro lado de uma fronteira de processo.

É só isso. Não há inteligência nova, não há modelo novo, não há nada que a Tool passe a saber e não soubesse. O que muda é quem executa:

Dentro do script (Unidade 2) Servidor MCP (aqui)
Quem executa a Tool o processo do agente um processo separado, seu
Quem pode usar um agente qualquer cliente que conectar
Como o agente descobre a Tool está no código dele pergunta ao servidor: list_tools
O que atravessa um objeto Python uma mensagem JSON
Onde mora a correção em cada cópia num arquivo só

A última linha é o ponto todo. O dataAjuizamento em dois formatos foi descoberto depois de a Unidade 2 estar escrita. Num servidor, esse conserto é feito uma vez.

Por que local, e não na nuvem

A maior parte do material sobre MCP que você vai encontrar mostra um servidor sendo consumido por um assistente comercial rodando em algum data center. Funciona, e há um apêndice sobre isso ao final do curso.

Não é o que este capítulo faz, por três razões que importam a um magistrado:

A chave e o filtro ficam do seu lado. O servidor lê DATAJUD_API_KEY do seu ambiente e aplica TETO_SIGILO no seu código. Nada disso viaja. Quem conecta no servidor recebe processos já filtrados — nunca a chave, nunca a consulta crua.

Não existe porta aberta. O transporte que vamos usar é o stdio: o cliente lança o servidor como um processo filho e conversa com ele por entrada e saída padrão, os mesmos canos por onde um programa de terminal escreve. Não há porta de rede, não há endereço, não há o que um scanner encontre. O servidor nasce quando o agente abre e morre quando o agente fecha.

Você não depende de ninguém para mudar. Um servidor local é um arquivo .py seu. Quem escreve a Tool decide o que ela devolve, o que ela recusa e o que ela nunca vai deixar sair.

Nota

Esta é a razão de fundo do curso inteiro: se o servidor é local, qualquer um pode criar o seu. Um servidor hospedado precisa de infraestrutura, de aprovação, de contrato e de alguém que o mantenha. Um servidor local precisa de um arquivo e de um pip install.

Instalando o SDK

Se você seguiu o Passo 8 da Unidade 0, já está instalado — confira com python verificar_ambiente.py, que agora tem uma linha para isso. Se não:

pip install "smolagents[litellm,mcp]" "mcp<2" websockets

O <2 não é frescura, e vale saber por quê antes de tropeçar nisso.

O SDK do MCP em Python está na versão 2, onde a classe FastMCP foi renomeada para MCPServer. Só que o adaptador que o smolagents usa para consumir servidores MCP (o mcpadapt) ainda fala com a versão 1. Instalando o mais novo, o próximo capítulo morre assim:

ImportError: cannot import name 'streamablehttp_client' from 'mcp.client.streamable_http'.
Did you mean: 'streamable_http_client'?

E o websockets está ali porque o mcpadapt importa o cliente de websocket mesmo sem usá-lo; sem ele, o erro que aparece é enganoso:

ModuleNotFoundError: Please install 'mcp' extra to use MCPClient: pip install 'smolagents[mcp]'

— que é justamente o que você já instalou.

Atenção

Guarde a forma deste problema, porque ela vai se repetir muito além do MCP: duas bibliotecas suas dependem da mesma terceira, em versões diferentes. A mensagem de erro fala da terceira, e não das duas.

Quando isso acontecer, o comando que responde é pip list, não a documentação.

O servidor

unidade3/exemplos/servidor_mcp.py:

import sys
from pathlib import Path

# O cliente MCP lança este arquivo a partir do diretório DELE, não do seu. {: #o-cliente-mcp-lança-este-arquivo-a-partir-do-diretório-dele-não-do-seu }
# Um caminho relativo ("../../unidade2/exemplos") funciona quando você roda na {: #um-caminho-relativo-unidade2exemplos-funciona-quando-você-roda-na }
# mão e quebra quando o agente é quem lança. Ancorar em __file__ resolve os {: #mão-e-quebra-quando-o-agente-é-quem-lança-ancorar-em-file-resolve-os }
# dois casos, e é o único lugar deste servidor onde há alguma esperteza. {: #dois-casos-e-é-o-único-lugar-deste-servidor-onde-há-alguma-esperteza }
EXEMPLOS_U2 = Path(__file__).resolve().parents[2] / "unidade2" / "exemplos"
sys.path.insert(0, str(EXEMPLOS_U2))

from mcp.server.fastmcp import FastMCP

import tools_cnj

servidor = FastMCP(
    "cnj-tjrj",
    instructions=(
        "Consulta processos do TJRJ na API Pública do DataJud (CNJ). "
        "Devolve apenas processos públicos, de nível de sigilo 0. "
        "Para buscar por assunto, chame listar_assuntos primeiro: "
        "buscar_processos exige o nome exato do assunto."
    ),
)


@servidor.tool()
def listar_assuntos(termo: str) -> dict:
    """Nomes de assunto do TJRJ que contêm o termo procurado.

    Serve para descobrir como o CNJ escreve um assunto antes de buscá-lo:
    'violência doméstica' devolve 'Violência Doméstica Contra a Mulher'.
    Devolve no máximo 20 nomes, em {"assuntos": [...]}.
    """
    return {"assuntos": tools_cnj.listar_assuntos(termo)}


@servidor.tool()
def buscar_processos(assunto: str, quantidade: int = 5) -> dict:
    """Processos públicos mais recentes de um assunto, do mais novo ao mais antigo.

    O `assunto` precisa ser o nome exato, tal como devolvido por
    listar_assuntos — nome aproximado devolve lista vazia, em silêncio.
    `quantidade` vai até 20. Devolve {"assunto": ..., "processos": [...]},
    cada processo com número, data de ajuizamento, grau, classe e órgão.
    """
    return {
        "assunto": assunto,
        "processos": tools_cnj.buscar_processos(assunto, quantidade),
    }


@servidor.tool()
def ultimos_movimentos(numero_processo: str, quantidade: int = 5) -> dict:
    """Os últimos andamentos de um processo, do mais recente para o mais antigo.

    Aceita o número com ou sem máscara: 0018683-81.2020.8.19.0066 e
    00186838120208190066 dão no mesmo. `quantidade` vai até 20.

    Junta os graus em que o processo tramitou numa lista só, ordenada por
    data. Devolve {"numero_processo": ..., "movimentos": [...]}, e apenas os
    `quantidade` mais recentes: um processo do TJRJ chega a 613 andamentos, e
    devolver todos estouraria a janela do modelo que fez a pergunta.
    """
    return {
        "numero_processo": numero_processo,
        "movimentos": tools_cnj.ultimos_movimentos(numero_processo, quantidade),
    }


if __name__ == "__main__":
    servidor.run(transport="stdio")

Repare no que não está neste arquivo: nenhuma consulta ao CNJ, nenhum filter de sigilo, nenhum _source, nenhum tratamento de data. Tudo isso continua em tools_cnj.py, onde sempre esteve. O servidor é uma tomada, não um aparelho.

E repare no que cada Tool virou: duas linhas em volta da função da Unidade 2. Por que duas linhas e não servidor.add_tool(ultimos_movimentos) direto — que funciona e é mais curto? Porque a versão curta perde dado. Isso foi medido, e é a próxima seção.

Rodando

python exemplos\servidor_mcp.py

E então: nada. Nenhuma mensagem, nenhum prompt, o cursor parado.

Está certo. Um servidor stdio não fala sozinho — ele espera um cliente falar primeiro. Ctrl+C encerra. Na prática você quase nunca vai rodá-lo assim: quem o lança é o cliente, no próximo capítulo.

O que dá para ver agora é o que o cliente vê ao conectar. Este é o anúncio de uma das três Tools, exatamente como trafega:

{
  "name": "ultimos_movimentos",
  "description": "Os últimos andamentos de um processo, do mais recente para o mais antigo.\n\nAceita o número com ou sem máscara: ...",
  "inputSchema": {
    "type": "object",
    "properties": {
      "numero_processo": {"title": "Numero Processo", "type": "string"},
      "quantidade": {"title": "Quantidade", "type": "integer", "default": 5}
    },
    "required": ["numero_processo"],
    "title": "ultimos_movimentosArguments"
  }
}

Três coisas, e você não escreveu nenhuma delas duas vezes: o nome da função, o esquema dos argumentos (tirado das anotações de tipo) e a descrição (tirada da docstring).

Nota

Isto responde a uma pergunta que costuma incomodar quem escreve a primeira Tool: por que tanta cerimônia com docstring e anotação de tipo, se o Python não obriga?

Porque não é decoração — é a interface. Dentro do script, a docstring era o que o modelo lia para decidir se chamava a Tool. Publicada, ela é o contrato que qualquer cliente vai ler. Uma Tool bem escrita para o agente já era uma Tool MCP; o que faltava era o transporte.

A versão de três linhas, e o que ela quebra

A primeira tentativa deste servidor foi mais bonita que a final:

servidor.add_tool(listar_assuntos)
servidor.add_tool(buscar_processos)
servidor.add_tool(ultimos_movimentos)

Três linhas, zero reescrita, as funções da Unidade 2 publicadas exatamente como estão. Sobe, conecta, responde. Parecia pronto.

Então medimos uma chamada de ultimos_movimentos com quantidade=5, pelo cliente que o próximo capítulo vai usar:

tool ultimos_movimentos returned multiple content, using the first one

Cinco movimentos entraram; um saiu.

O motivo está no protocolo: a resposta de uma Tool MCP é uma lista de blocos de conteúdo. Quando a função devolve uma lista Python de 5 itens, o SDK gera 5 blocos — um por item. E o adaptador do smolagents lê o primeiro e descarta o resto. Não é erro: é um aviso, numa linha, no meio do log.

O que a função devolve Blocos de conteúdo O que o agente recebe
list com 5 movimentos 5 1 movimento
dict com a lista dentro 1 5 movimentos

Daí as duas linhas de casca. Devolver {"movimentos": [...]} em vez de [...] atravessa como um bloco só, e nada se perde.

Esta é metade da regra. A outra metade mora no cliente, e o próximo capítulo mostra a medição das duas juntas: um servidor certo com um cliente mal configurado ainda entrega o dado como texto, e o modelo quebra ao tentar usá-lo.

Atenção

Vale reler o que aconteceu aqui, porque é a terceira vez neste curso que acontece a mesma coisa.

Na Unidade 2, buscar_processos devolvia cinco processos de 2026, formatados, e estava errada. Na Unidade 3, uma série anual mostrava queda de 99% em violência doméstica, e estava errada. Agora, uma Tool devolve um movimento perfeitamente formatado — e faltam quatro.

Nenhuma das três falhou. Todas as três responderam. Saída bonita não é evidência; o que é evidência é ter contado quantos itens entraram e quantos saíram.

E há um ganho de brinde na reescrita. Publicadas com as docstrings originais — que falavam para quem lia o código, com frases como "ver CORTE_DE_FORMATO" —, as três Tools ocupavam 2.652 bytes de anúncio. Reescritas para um estranho, dizendo o que entra, o que sai e o que não sai, ocupam 1.997 bytes. Vinte e cinco por cento a menos de contexto gasto em toda sessão de todo agente que conectar, e mais claras.

O stdout é o cabo

Esta é a regra que mais quebra servidor de principiante, e ela não perdoa:

Nunca imprima na saída padrão dentro de um servidor stdio.

No transporte stdio, o stdout do servidor é o cano por onde trafegam as mensagens do protocolo. Um print() de depuração não aparece na tela de ninguém — ele entra no cano como se fosse uma mensagem. Foi medido: um print("DEPURANDO: chamou", nome) dentro de uma Tool produz, do lado do cliente,

Failed to parse JSONRPC message from server
pydantic_core._pydantic_core.ValidationError: Invalid JSON: expected value at line 1 column 1
  [input_value='DEPURANDO: chamou listar_assuntos\r', input_type=str]

Neste SDK o cliente descarta a linha inválida e a sessão sobrevive; em outros, ela cai. De todo modo, o erro reclama de um JSON que você não escreveu, e não menciona o seu print.

Para depurar, escreva no stderr, que não é o cano:

print("cheguei aqui", file=sys.stderr)

O que a travessia custa

Medido nesta máquina, com o servidor rodando localmente e o índice do TJRJ do outro lado da internet:

Tempo
Subir o servidor + handshake (uma vez por sessão) 2,23 s
list_tools — descobrir as três Tools 0,017 s
buscar_processos dentro do processo 0,73 s
buscar_processos via MCP 0,30 s
ultimos_movimentos dentro do processo 0,20 s
ultimos_movimentos via MCP 0,17 s

As linhas de dentro e de fora estão invertidas em relação ao que a intuição diz, e o motivo é honesto: a diferença é menor que o ruído da rede. As duas chamadas gastam quase todo o tempo esperando o CNJ; o que a fronteira de processo acrescenta é pequeno demais para ser visto em cima disso.

O custo real do MCP é outro, e é de uma vez só: os 2,23 segundos para subir o processo. Um agente que suba o servidor a cada pergunta paga esse pedágio a cada pergunta; um que conecte uma vez e faça vinte chamadas, paga uma vez.

E o que não se perdeu na travessia:

erro: 0,007 s
Error executing tool ultimos_movimentos: '123' não é um número de processo.
São 20 dígitos, com ou sem máscara.

Sete milésimos, sem tocar no CNJ. A guarda antes da rede continua valendo do outro lado da fronteira, e a mensagem que ensina o próximo argumento chega inteira ao modelo.

Atenção

Isso vale para o SDK 1.x, que é o que este capítulo instala. Na versão 2, o comportamento mudou: qualquer exceção que o SDK não reconheça vira queda do servidor, o cliente recebe apenas Error executing tool ultimos_movimentos, e a sua frase fica no log do servidor, onde o modelo nunca vai ler. Lá, para a mensagem viajar, é preciso levantar ToolError em vez de ValueError.

Se um dia você migrar, é a primeira coisa a testar — e ela falha em silêncio: o agente só fica um pouco mais burro.

O sigilo atravessa junto

Vale dizer isto em voz alta, porque é o que torna o desenho defensável.

O filtro de sigilo não está no servidor. Está em tools_cnj.py, dentro de cada consulta, onde sempre esteve:

{"range": {"nivelSigilo": {"lte": TETO_SIGILO}}}

A consequência é a que interessa: todo cliente que conectar herda o filtro, sem poder desligá-lo. Um cliente não pede uma consulta — pede uma Tool. Ele não escolhe o filter, não escolhe o _source, não escolhe o teto de movimentos. Quem escolheu foi você, quando escreveu a função.

Duas linhas de conduta decorrem daí, e as duas são sobre não desfazer isso:

Não exponha este servidor na rede. O FastMCP também sabe rodar por HTTP (transport="streamable-http"), e é uma linha. Não faça. Um servidor stdio não tem porta, não tem endereço e morre com o cliente; um servidor HTTP é um serviço, com tudo que um serviço exige e que não cabe numa máquina de trabalho.

Não publique Tool que aceite consulta crua. É tentador escrever consultar_datajud(corpo_da_query) e deixar o modelo montar o Elasticsearch. Uma Tool assim entrega ao modelo o poder de escrever o próprio filter — inclusive de omitir o do sigilo. O capítulo anterior existe justamente para tornar isso desnecessário: as cinco formas de pergunta cobrem o que se precisa perguntar, e cada uma delas leva o filtro embutido.

A lição

Um servidor MCP não acrescenta capacidade nenhuma às suas Tools. Ele muda quem pode usá-las — e por isso muda o que uma correção sua alcança.

O dataAjuizamento em dois formatos foi encontrado por uma pessoa, num índice, num dia. Dentro de um script, esse conserto vale para um agente. Publicado num servidor, vale para todo agente que conectar, inclusive os que ainda não existem.

E há uma segunda lição, menor e mais útil no dia a dia: fronteira é onde o dado se perde em silêncio. Cinco movimentos viraram um, e nada falhou. O que revelou não foi ler a documentação do protocolo — foi contar os itens dos dois lados.

Exercício

Publique a sua Tool — aquela que você escreveu no exercício do capítulo anterior, com a forma de pergunta que escolheu.

  1. Acrescente-a ao servidor_mcp.py, com @servidor.tool(), devolvendo um dicionário.
  2. Reescreva a docstring pensando em um estranho: o que entra, o que sai, e o que a Tool não devolve. Compare com a docstring que você tinha escrito para si mesmo.
  3. Suba o servidor e confira que ele aparece na lista — e, principalmente, conte os itens: quantos a sua função devolve dentro do Python, e quantos chegam do outro lado?

Se os dois números forem diferentes, você acabou de encontrar sozinho o defeito que esta página levou uma tarde para achar.

No próximo capítulo o servidor ganha um cliente: o mesmo agente com Ollama da Unidade 2, agora enxergando Tools que não estão dentro dele.