Pular para o conteúdo

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. (...)
Atenção

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.

Nota

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.

Nota

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

  1. Rode alfred_mcp.py com structured_output=False — o padrão de hoje. Leia o erro que aparece e confirme que o código escrito pelo modelo estava correto.
  2. Tire a chamada a aceitar_posicional e rode de novo. Conte quantos passos o agente gasta repetindo o mesmo erro antes de desistir.
  3. 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.
  4. 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.