O agente que não tem Tool nenhuma
O servidor do capítulo anterior está de pé e ninguém o usa. Falta o cliente.
O cliente é o Alfred da Unidade 2 — mesmo modelo local, mesmo CodeAgent, mesma pergunta sobre violência doméstica. A única diferença é que este arquivo não tem um @tool sequer. As três Tools chegam prontas, de outro processo, no momento da conexão.
Foi a primeira coisa deste curso que não funcionou de primeira. Foram três defeitos, um atrás do outro, e nenhum deles apareceu como erro no lugar certo. Este capítulo é o registro honesto dos três — porque quem for escrever o próprio agente vai encontrar os mesmos.
O que muda no agente: nada
Na Unidade 2, o agente era construído assim:
agente = CodeAgent(tools=[listar_assuntos, buscar_processos], model=modelo)
Agora:
with MCPClient(parametros, structured_output=True) as ferramentas:
agente = CodeAgent(tools=ferramentas, model=modelo)
A lista de Tools deixou de ser escrita à mão e passou a ser recebida. O CodeAgent não sabe da diferença: para ele, é uma lista de objetos Tool, como sempre foi.
Três coisas na linha do with merecem atenção, e as três já custaram tempo a alguém.
Quem lança o servidor é o cliente. Não existe "subir o servidor antes". O MCPClient abre o processo filho, faz o handshake, pede a lista de Tools — tudo ao entrar no with. Ao sair, mata o processo.
O with não é estilo. Um agente criado dentro do bloco e chamado fora dele encontra Tools mortas: o servidor já foi encerrado. Tudo o que usa as Tools mora dentro do bloco.
structured_output=True não é detalhe. É o terceiro defeito deste capítulo, e chega lá embaixo.
O cliente
import sys
from pathlib import Path
from mcp import StdioServerParameters
from smolagents import CodeAgent, LiteLLMModel, MCPClient
SERVIDOR = str(Path(__file__).resolve().parent / "servidor_mcp.py")
parametros = StdioServerParameters(command=sys.executable, args=[SERVIDOR])
Duas armadilhas nessas duas linhas.
command=sys.executable é o interpretador que está rodando este arquivo — o do seu .venv. A tentação é escrever command="python", e aí o servidor sobe com o primeiro Python do PATH, que pode ser o do sistema: sem smolagents, sem mcp, sem nada. O erro que isso produz fala de módulo não encontrado, num processo que você não está vendo.
O caminho do servidor precisa ser absoluto, porque o processo filho nasce no diretório do cliente, não no seu.
O anúncio atravessa, mas perde coisas
Antes de rodar, vale ver o que o modelo passa a ler. A mesma Tool, dentro do script e via MCP, no prompt de sistema do agente:
# dentro do script {: #dentro-do-script }
def listar_assuntos(termo: string) -> array:
# via MCP {: #via-mcp }
def listar_assuntos(termo: string) -> object:
Quase igual. O -> array virou -> object: o adaptador declara tudo como object, porque do outro lado da fronteira ele não tem como saber. E há uma perda menos visível, no campo inputs:
description do argumento termo |
|
|---|---|
| dentro do script | parte do nome do assunto, por exemplo "violência doméstica". |
| via MCP | see tool description |
A descrição por argumento — aquela que a seção Args: da docstring produz — não atravessa. Ela vira um ponteiro para a descrição geral. O texto não some (continua na docstring publicada), mas deixa de estar colado no argumento.
Nada disso quebra nada. Guarde mesmo assim: a assinatura que o modelo lê promete uma função Python normal. O primeiro defeito nasce dessa promessa.
Primeira tentativa: seis passos, zero processos, uma resposta
Rodada real, servidor no ar, mesma pergunta da Unidade 2:
Executing parsed code:
violencia_domestica_assuntos = listar_assuntos('violência doméstica')
Code execution failed at line 'violencia_domestica_assuntos =
listar_assuntos('violência doméstica')' due to: ValueError: tool
listar_assuntos does not support multiple positional arguments or combined
positional and keyword arguments
O modelo escreveu o que qualquer pessoa escreveria, lendo def listar_assuntos(termo: string). Errou nos passos 4, 5 e 6, sempre igual, e bateu no max_steps.
E então fez isto:
RESPOSTA FINAL:
Apesar de ter falhado ao tentar obter os processos mais recentes sobre
violência doméstica através da ferramenta 'listar_assuntos', posso fornecer
uma resposta geral para o seu pedido.
Os três processos mais recentes sobre violência doméstica podem ser:
1. **Processo X**: Este processo foi iniciado recentemente e está focado na
violência doméstica. (...)
Pare aqui e leia de novo o que o agente entregou.
Ele não conseguiu consultar o CNJ. Nenhuma chamada deu certo. E mesmo assim devolveu uma resposta com três processos, com estrutura, com vocabulário jurídico correto, com aparência de trabalho feito.
Numa segunda rodada, corrigido só o primeiro defeito, ele fez de novo — desta vez mais curto: "1. Processo 1, 2. Processo 2, 3. Processo 3".
Um agente que falha não fica em silêncio. Ele preenche. É a lição mais cara desta unidade inteira, e a única que vale para qualquer agente, de qualquer modelo, em qualquer tarefa. Um magistrado que peça processos e receba uma lista bem formatada não tem, na resposta, nada que distinga o dado do invento.
Nas rodadas acima, o modelo até avisou ("esses processos são exemplos e não são processos reais"). Não conte com isso. Conte com o número do processo: ele existe ou não existe, e é verificável.
Defeito 1: a assinatura mente
A causa está no adaptador, em mcpadapt/smolagents_adapter.py:
def forward(self, *args, **kwargs):
if len(args) > 0:
if len(args) == 1 and isinstance(args[0], dict) and not kwargs:
mcp_output = func(args[0])
else:
raise ValueError(
f"tool {self.name} does not support multiple positional arguments..."
)
else:
mcp_output = func(kwargs)
Um argumento posicional só é aceito se for um dicionário com todos os argumentos. Uma string posicional cai no raise. Só chamada por nome funciona:
listar_assuntos('violência doméstica') # ValueError
listar_assuntos(termo='violência doméstica') # funciona
Duas saídas, e as duas foram medidas.
Pedir ao modelo. Acrescentar à pergunta "Chame as Tools sempre com argumentos nomeados". Funciona: zero erros de posicional na rodada seguinte. Mas depende de o modelo obedecer, gasta contexto em toda pergunta, e não protege o agente de ninguém mais.
Consertar no cliente. Seis linhas, determinístico, e o modelo não precisa saber de nada:
def aceitar_posicional(ferramenta):
"""Deixa uma Tool vinda do MCP ser chamada como qualquer função Python."""
nomes = list(ferramenta.inputs)
original = ferramenta.forward
def forward(*args, **kwargs):
kwargs.update(dict(zip(nomes, args)))
return original(**kwargs)
ferramenta.forward = forward
return ferramenta
Os nomes vêm de ferramenta.inputs, que veio do anúncio do servidor, que veio da assinatura da função. A ordem é a mesma. Casar posicionais com nomes é só isso.
O curso usa a segunda. A primeira fica registrada porque é o que a maioria vai tentar primeiro, e porque saber que ela funciona ajuda a diagnosticar.
Defeito 2: chegou uma string, não um dicionário
Corrigido o posicional, a chamada passou. E a linha seguinte quebrou:
InterpreterError: Could not index {
"assuntos": [
"Decorrente de Violência Doméstica",
"Violência Doméstica Contra a Mulher"
]
} with 'assuntos': TypeError: string indices must be integers, not 'str'
Olhe o que o erro imprime: um dicionário, com as chaves certas, com os dados certos. E mesmo assim ['assuntos'] não funciona.
Porque não é um dicionário. É o texto de um dicionário. O adaptador, na configuração padrão, entrega ao modelo o campo de texto do bloco de conteúdo — uma string que se parece com um objeto. O modelo escreveu o código certo e levou um erro que não é culpa dele.
Esse TypeError: string indices must be integers, not 'str' já apareceu neste curso, na Unidade 0, por um motivo completamente diferente: mudança de API do smolagents no campo content.
Mesma mensagem, causas sem relação nenhuma. É um bom lembrete de que a mensagem de erro diz onde quebrou, não por quê.
O conserto é um parâmetro:
with MCPClient(parametros, structured_output=True) as ferramentas:
Com ele, o adaptador usa o structuredContent da resposta MCP — o objeto de verdade — em vez do texto. Sem ele, você recebe strings.
A biblioteca inclusive avisa, quando o parâmetro não é passado:
FutureWarning: Parameter 'structured_output' was not specified. Currently it
defaults to False, but in version 1.25, the default will change to True.
Passe explicitamente. Um aviso de mudança de padrão é o tipo de coisa que resolve o seu problema hoje e cria outro daqui a seis meses, quando o padrão mudar sob os seus pés.
As duas metades da mesma regra
Aqui o capítulo anterior e este se encontram. No servidor, a regra era devolver dict, não list. No cliente, é structured_output=True. As duas foram medidas juntas, com um servidor de prova que publica a mesma consulta das duas formas:
| Tool devolve | structured_output=False |
structured_output=True |
|---|---|---|
list com 2 itens |
string, truncada em 1 | string, ainda truncada em 1 |
dict com a lista dentro |
string inteira (o modelo quebra ao indexar) | dicionário inteiro, 2 itens |
Uma metade sem a outra não resolve. Servidor certo com cliente errado entrega string. Cliente certo com servidor errado entrega um item de dois — com um aviso novo, igualmente discreto:
tool como_lista expected structured output but got unparseable text: Decorrente de Violência Doméstica...
Três das quatro células dessa tabela produzem uma resposta. Uma só produz a resposta certa.
Defeito 3: o modelo é pequeno
Com os dois consertos no lugar, as Tools funcionam e o agente ainda tropeça:
processos_assunto_1 = buscar_processos(violencia_domestica_assuntos.get(0), quantidade)
Error executing tool buscar_processos: 1 validation error for buscar_processosArguments
assunto
Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]
O qwen2:7b chamou .get(0) num dicionário cujas chaves são strings, recebeu None, e mandou None para a Tool. O embrulho {"assuntos": [...]} — que existe para o dado atravessar inteiro — é um nível a mais para um modelo de 7 bilhões de parâmetros navegar.
Este não é defeito da fronteira. É o mesmo limite que a Unidade 2 já documentou, quando foi preciso pedir "assim que tiver os processos, chame final_answer" para o agente não morrer por max_steps. A saída é a mesma: dizer a forma na pergunta.
"As Tools devolvem dicionários: listar_assuntos devolve {'assuntos': [...]} "
"e buscar_processos devolve {'processos': [...]}. "
Uma linha na pergunta, não uma reescrita da Tool. Vale registrar sem eufemismo: parte do que se chama "engenharia de prompt" é compensar um modelo pequeno. Num modelo maior essa linha seria desnecessária. Rodar local tem um preço, e ele aparece aqui.
O agente completo
r"""
Alfred, agora sem Tool nenhuma dentro dele.
Procure por um @tool aqui. Não tem.
"""
import sys
from pathlib import Path
from mcp import StdioServerParameters
from smolagents import CodeAgent, LiteLLMModel, MCPClient
sys.stdout.reconfigure(encoding="utf-8")
SERVIDOR = str(Path(__file__).resolve().parent / "servidor_mcp.py")
parametros = StdioServerParameters(command=sys.executable, args=[SERVIDOR])
def aceitar_posicional(ferramenta):
"""Deixa uma Tool vinda do MCP ser chamada como qualquer função Python."""
nomes = list(ferramenta.inputs)
original = ferramenta.forward
def forward(*args, **kwargs):
kwargs.update(dict(zip(nomes, args)))
return original(**kwargs)
ferramenta.forward = forward
return ferramenta
modelo = LiteLLMModel(
model_id="ollama_chat/qwen2:7b",
api_base="http://127.0.0.1:11434",
num_ctx=8192,
temperature=0,
)
if __name__ == "__main__":
with MCPClient(parametros, structured_output=True) as brutas:
ferramentas = [aceitar_posicional(f) for f in brutas]
print(f"Conectado. {len(ferramentas)} Tools vieram do servidor:")
for f in ferramentas:
print(f" - {f.name}({', '.join(f.inputs)})")
agente = CodeAgent(
tools=ferramentas,
model=modelo,
max_steps=6,
additional_authorized_imports=[],
)
resposta = agente.run(
"Traga os 3 processos mais recentes sobre violência doméstica. "
"Use somente os dados devolvidos pelas Tools. "
"Assim que tiver os processos, chame final_answer com eles. "
"As Tools devolvem dicionários: listar_assuntos devolve "
"{'assuntos': [...]} e buscar_processos devolve {'processos': [...]}. "
"Responda em português."
)
print(resposta)
Ao rodar, a primeira coisa que aparece é a lista que veio de fora:
Conectado. 3 Tools vieram do servidor:
- listar_assuntos(termo)
- buscar_processos(assunto, quantidade)
- ultimos_movimentos(numero_processo, quantidade)
Nenhuma delas está no arquivo.
E a resposta, na rodada medida — 2 passos de execução, 125,2 segundos, contra os 6 passos e 887 segundos da tentativa que inventou:
[{'assunto': 'Decorrente de Violência Doméstica',
'numero_processo': '0011627-46.2026.8.19.0014',
'data_ajuizamento': '15/08/2026', 'grau': 'G1',
'classe': 'Auto de Prisão em Flagrante',
'orgao_julgador': 'CAMPOS DOS GOYTACAZES - CENTRAL DE AUDIEN CUSTODIA'},
{'assunto': 'Decorrente de Violência Doméstica',
'numero_processo': '0076931-31.2026.8.19.0001',
'data_ajuizamento': '14/08/2026', 'grau': 'JE',
'classe': 'Ação Penal - Procedimento Sumaríssimo',
'orgao_julgador': 'CAPITAL IV JUI ESP CRIM'},
... ]
Números que existem, datas que batem, órgãos que são órgãos. A diferença entre esta resposta e a do começo do capítulo não está na aparência — está em serem verificáveis.
Conte os itens antes de comemorar. Duas execuções da mesma pergunta, com o mesmo código e temperature=0:
| itens devolvidos | passos de execução | tempo | |
|---|---|---|---|
| 1ª execução | 6 | 2 | 125,2 s |
| 2ª execução | 3 | 2 | 145,7 s |
Na primeira, o agente encontrou dois nomes de assunto (Decorrente de Violência Doméstica e Violência Doméstica Contra a Mulher) e pediu três de cada. Na segunda, juntou os dois e devolveu três no total.
Nenhuma das duas está errada — a pergunta é que era ambígua, e o agente resolveu a ambiguidade sozinho, de um jeito diferente a cada vez, sem dizer qual escolheu. temperature=0 reduz a variação do texto; não elimina a variação da decisão.
Repare também na ordem da segunda resposta: 17/06, 16/06, 15/08. "Mais recentes" valeu dentro de cada assunto, não entre eles.
Se você precisa de três, peça três do total; se precisa de ordem, peça a ordem. E, das duas vezes, o hábito é o mesmo das Unidades 2 e 3: conte o que voltou.
O que isto significa na prática
O agente da Unidade 2 e este fazem a mesma coisa. A diferença aparece no dia em que a Tool precisa mudar.
| Tool no script | Tool no servidor MCP | |
|---|---|---|
| Corrigir o filtro de sigilo | edita o seu arquivo | edita o servidor; todo cliente pega |
| Um colega quer usar | manda o arquivo, ele adapta | ele aponta o cliente dele |
| Outro agente seu | copia e cola | aponta para o mesmo servidor |
| Custo por sessão | zero | ~2,2 s de handshake |
| Custo por chamada | nada | nada mensurável |
Nada disso é ganho de capacidade. É ganho de alcance da correção — e é por isso que o servidor é local: a Tool sai do script sem que o dado saia da máquina.
A lição
Três defeitos, e o padrão dos três é o mesmo do curso inteiro.
O posicional deu erro no lugar errado: o código do modelo estava certo para a assinatura que ele leu. A string em vez do dicionário deu um erro que já significou outra coisa neste curso. O .get(0) não deu erro nenhum — devolveu None e seguiu.
E o pior de todos não foi defeito de código: foi o agente entregar uma resposta bem escrita depois de falhar em tudo.
A fronteira não acrescenta poder nenhum ao agente. Ela acrescenta lugares onde o dado se transforma em silêncio — e um agente que perdeu o dado não avisa: ele responde mesmo assim.
Para um magistrado, isso reduz a uma regra operacional: número de processo é verificável, prosa não é. Peça ao agente o número, e confira o número.
Exercício
- Rode
alfred_mcp.pycomstructured_output=False— o padrão de hoje. Leia o erro que aparece e confirme que o código escrito pelo modelo estava correto. - Tire a chamada a
aceitar_posicionale rode de novo. Conte quantos passos o agente gasta repetindo o mesmo erro antes de desistir. - Deixe os dois consertos, mas remova a linha que descreve a forma dos dicionários na pergunta. Rode três vezes. Anote quantas terminam com processos reais e quantas terminam com prosa.
- Em qualquer rodada que termine sem processos: pegue a resposta final e pergunte-se, sem olhar o log, se você saberia dizer que ela é inventada.
O agente consome Tools que não estão dentro dele, na sua máquina, sem porta aberta. Falta o que sempre falta: o que fazer quando quebra. É o próximo capítulo.