Objetivo da Aula
Ao concluir esta aula, você será capaz de:
Implementar a mesma funcionalidade via tool use direto e via MCP, comparando a estrutura de cada abordagem
Avaliar trade-offs concretos de overhead, reusabilidade, latência e manutenibilidade entre as duas abordagens
Decidir qual abordagem usar com base em critérios objetivos: escopo, maturidade, multi-cliente
Migrar código de tool use direto para MCP quando o caso justifica a mudança
Identificar os pontos onde as duas abordagens convergem (o agent loop é idêntico)
Por que isso importa
Nem todo projeto precisa de MCP. Para uma automação rápida que você vai usar uma vez, implementar um MCP server é overhead desnecessário. Para um sistema de integração que três times diferentes vão consumir via Claude Desktop, Cursor e uma API interna, MCP elimina triplicação de código.
A armadilha mais comum: usar MCP para tudo por ser “mais profissional”, sem calcular o custo real de setup. A segunda armadilha: manter tool use direto indefinidamente quando o sistema claramente evoluiu para caso de uso multi-cliente.
Esta unidade te dá a régua para medir cada caso.
Conceitos Fundamentais
Tool Use Direto: A Abordagem Acoplada
No tool use direto, a definição e execução da ferramenta vivem no mesmo código que chama o LLM:
// TUDO no mesmo lugar:
// 1. Definição da tool (schema)
const tools: Anthropic.Tool[] = [{
name: 'buscar_acao',
description: 'Busca cotação da B3',
input_schema: {
type: 'object' as const,
properties: { ticker: { type: 'string' } },
required: ['ticker'],
}
}]
// 2. Execução da tool (lógica de negócio)
function executarTool(nome: string, args: Record<string, unknown>): string {
if (nome === 'buscar_acao') {
const { ticker } = args as { ticker: string }
return JSON.stringify(getAcaoMock(ticker))
}
throw new Error(`Tool desconhecida: ${nome}`)
}
// 3. Agent loop
async function perguntarDireto(pergunta: string) {
const mensagens = [{ role: 'user' as const, content: pergunta }]
while (true) {
const r = await client.messages.create({ model, max_tokens: 300, tools, messages: mensagens })
mensagens.push({ role: 'assistant', content: r.content })
if (r.stop_reason === 'end_turn') return r.content.find(b => b.type === 'text')?.text
if (r.stop_reason === 'tool_use') {
for (const b of r.content) {
if (b.type !== 'tool_use') continue
const resultado = executarTool(b.name, b.input as Record<string, unknown>)
mensagens.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: b.id, content: resultado }] })
}
}
}
}Vantagens: - Zero overhead de protocolo - Zero configuração de servidor ou transports - Código mais simples — lê-se de cima para baixo - Deploy junto com a aplicação — sem processo separado para gerenciar
Desvantagens: - Acoplado ao código da aplicação — para usar em outra app, copia o código - Específico para Anthropic — migrar para OpenAI exige reescrever o schema - Não é reutilizável por outros clientes (Claude Desktop, Cursor, etc.) - executarTool() cresce indefinidamente com mais tools — switch gigante
MCP: A Abordagem Desacoplada
No MCP, o server (ferramentas + lógica) é separado do client (LLM + agent loop):
// SERVER (processo separado ou in-process):
// Contém: definição de tools + lógica de execução
const server = new Server({ name: 'acoes-b3', version: '1.0.0' }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...] }))
server.setRequestHandler(CallToolRequestSchema, async (req) => {
// lógica de execução aqui
})
// CLIENT (na aplicação):
// Contém: agent loop + conversão de schema
const { tools: mcpTools } = await mcpClient.listTools()
const anthropicTools = mcpTools.map(t => ({ ...t, input_schema: t.inputSchema }))
// ... agent loop igual ao tool use diretoVantagens: - Server reutilizável por qualquer cliente MCP - Muda de modelo LLM? Só muda o client — server continua igual - Equipe separada pode manter o server - Deploy independente — server pode ter ciclo de vida diferente do client - Scaling independente: server de ações B3 pode ter cache, rate limiting próprio
Desvantagens: - Mais arquivos para gerenciar - Setup inicial maior - Overhead de serialização JSON-RPC (mínimo, mas existe) - Debugging mais complexo — dois processos, dois pontos de falha
A Tabela de Decisão
Critério | Tool Use Direto | MCP |
Uma aplicação usa a ferramenta | ✓ melhor | |
Múltiplas aplicações usam | ✓ melhor | |
Prototipagem / script único | ✓ melhor | |
Sistema de produção de longa duração | ✓ melhor | |
Equipe única | ✓ ok | ✓ ok |
Equipes separadas | ✓ melhor | |
Apenas Anthropic como LLM | ✓ ok | ✓ ok |
Multi-LLM (Anthropic + OpenAI + …) | ✓ melhor | |
Claude Desktop / Cursor como cliente | ✓ único | |
Latência crítica (<10ms overhead importa) | ✓ melhor | |
< 5 tools simples | ✓ melhor | |
> 10 tools complexas | ✓ melhor |
O Que É Idêntico nas Duas Abordagens
O agent loop fundamental — while(stop_reason === 'tool_use') { callTool() } — é idêntico. A única diferença é onde callTool() busca a lógica:
Tool Use Direto: callTool() → executarTool() local → lógica inline
MCP: callTool() → mcpClient.callTool() → server remoto → lógica no serverSe você entende o agent loop (Unidade 02), você entende as duas abordagens. MCP apenas move a lógica de execução para um processo separado.
Aprofundamento Técnico
Abordagem MCP via InMemoryTransport
Para o lab, usamos InMemoryTransport para não precisar de processo separado. A estrutura é a mesma de produção, só o transport muda:
// Cria server de ações
function criarAcoesServer(): Server {
const server = new Server({ name: 'acoes-b3', version: '1.0.0' }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'buscar_acao',
description: 'Busca cotação de ação da B3 por ticker',
inputSchema: {
type: 'object',
properties: {
ticker: { type: 'string', description: 'Código da ação (PETR4, VALE3, etc.)' },
},
required: ['ticker'],
},
}],
}))
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { ticker } = req.params.arguments as { ticker: string }
const acao = getAcaoMock(ticker.toUpperCase())
if (!acao) {
return { content: [{ type: 'text', text: `Ação ${ticker} não encontrada` }], isError: true }
}
return {
content: [{
type: 'text',
text: JSON.stringify({
ticker: ticker.toUpperCase(),
nome: acao.nome,
preco: acao.preco,
variacao_dia: `${acao.variacao_dia}%`,
})
}]
}
})
return server
}
// Configura client conectado ao server
async function criarClientMCP(): Promise<Client> {
const server = criarAcoesServer()
const client = new Client({ name: 'client-acoes', version: '1.0.0' }, { capabilities: {} })
const [clientT, serverT] = InMemoryTransport.createLinkedPair()
await server.connect(serverT)
await client.connect(clientT)
return client
}
// Agent loop via MCP
async function perguntarViaMCP(pergunta: string): Promise<string> {
const mcpClient = await criarClientMCP()
const { tools: mcpTools } = await mcpClient.listTools()
const anthropicTools: Anthropic.Tool[] = mcpTools.map(t => ({
name: t.name,
description: t.description ?? '',
input_schema: t.inputSchema as Anthropic.Tool['input_schema'],
}))
const mensagens: Anthropic.MessageParam[] = [{ role: 'user', content: pergunta }]
while (true) {
const response = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 300,
tools: anthropicTools,
messages: mensagens,
})
mensagens.push({ role: 'assistant', content: response.content })
if (response.stop_reason === 'end_turn') {
const texto = response.content.find(b => b.type === 'text')
return texto?.type === 'text' ? texto.text : ''
}
if (response.stop_reason === 'tool_use') {
const results: Anthropic.ToolResultBlockParam[] = []
for (const b of response.content) {
if (b.type !== 'tool_use') continue
const result = await mcpClient.callTool({ name: b.name, arguments: b.input as Record<string, unknown> })
results.push({
type: 'tool_result',
tool_use_id: b.id,
content: result.content.filter(c => c.type === 'text').map(c => c.text).join('\n'),
})
}
mensagens.push({ role: 'user', content: results })
}
}
}Benchmark: Medindo o Overhead do MCP
Para verificar o overhead real do InMemoryTransport:
async function benchmark(pergunta: string, iteracoes: number = 5) {
console.log(`\nBenchmark: "${pergunta}" (${iteracoes} iterações)\n`)
// Tool use direto
const inicioDir = Date.now()
for (let i = 0; i < iteracoes; i++) await perguntarDireto(pergunta)
const tempoDir = (Date.now() - inicioDir) / iteracoes
// MCP
const inicioMCP = Date.now()
for (let i = 0; i < iteracoes; i++) await perguntarViaMCP(pergunta)
const tempoMCP = (Date.now() - inicioMCP) / iteracoes
console.log(`Tool Use Direto: ${tempoDir.toFixed(0)}ms avg`)
console.log(`MCP (InMemory): ${tempoMCP.toFixed(0)}ms avg`)
console.log(`Overhead MCP: ${(tempoMCP - tempoDir).toFixed(0)}ms`)
// Resultado esperado: overhead < 5ms (serialização JSON in-process)
}O overhead do InMemoryTransport é sub-milissegundo. Para HTTP/SSE, adicione a latência de rede (~5-50ms para localhost).
Exemplos Anotados
Exemplo 1: Mesma Query, Duas Abordagens
const PERGUNTA = 'Como está a VALE3 hoje? Ela está acima ou abaixo de R$ 65?'
// Abordagem 1: Tool Use Direto
// Pros: código mais curto, zero setup
// Cons: lógica de ação misturada com lógica de agente
async function abordagem1() {
const tools: Anthropic.Tool[] = [{
name: 'buscar_acao',
description: 'Busca cotação da B3',
input_schema: {
type: 'object' as const,
properties: { ticker: { type: 'string' } },
required: ['ticker'],
}
}]
const mensagens: Anthropic.MessageParam[] = [{ role: 'user', content: PERGUNTA }]
while (true) {
const r = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001', max_tokens: 300, tools, messages: mensagens
})
mensagens.push({ role: 'assistant', content: r.content })
if (r.stop_reason === 'end_turn') {
return r.content.find(b => b.type === 'text')?.type === 'text'
? r.content.find(b => b.type === 'text')!.text // as string — verificado
: ''
}
for (const b of r.content) {
if (b.type !== 'tool_use') continue
const { ticker } = b.input as { ticker: string }
const acao = getAcaoMock(ticker)
mensagens.push({
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: b.id,
content: JSON.stringify(acao ?? { erro: 'não encontrado' })
}]
})
}
}
}
// Abordagem 2: MCP
// Pros: server reutilizável, agnóstico de LLM
// Cons: mais setup, dois componentes
async function abordagem2() {
const client = await criarClientMCP() // server criado internamente
return perguntarViaMCP(PERGUNTA) // usa o client MCP
}
// Resultado das duas abordagens deve ser idêntico
const [r1, r2] = await Promise.all([abordagem1(), abordagem2()])
console.log('Direto:', r1)
console.log('MCP: ', r2)
// Texto diferente (LLM não é determinístico), mas semanticamente equivalentePadrões e Armadilhas
Padrões
Padrão 1: Comece com tool use direto, migre para MCP quando o sistema crescer Para < 5 tools em uma aplicação: tool use direto. Quando você se pega copiando código de tools entre projetos ou quando outro time pede acesso à mesma ferramenta: migre para MCP. A migração é estrutural, não conceptual — o agent loop não muda.
Padrão 2: Extraia executarTool() para arquivo separado antes de migrar para MCP
// tools/acoes.ts — extrai antes de migrar
export function executarBuscarAcao(ticker: string): string { ... }
// app.ts
import { executarBuscarAcao } from './tools/acoes'
function executarTool(nome: string, args: ...) {
if (nome === 'buscar_acao') return executarBuscarAcao(args.ticker)
}
// Quando migrar para MCP: move executarBuscarAcao para o server
// O server.setRequestHandler(CallToolRequestSchema) chama a mesma funçãoArmadilhas
⚠️ Armadilha 1: MCP para script de uso único
// OVERKILL: MCP para um script que roda uma vez
// 50 linhas de setup para salvar 10 linhas de código
// CERTO: tool use direto é suficiente⚠️ Armadilha 2: Tool use direto quando há 3+ consumers
// PROBLEMA: mesmo código de ferramenta copiado em 3 projetos
// Quando o endpoint da API mudar, você atualiza em 3 lugares
// CORRETO: MCP server centralizado, 3 clients apontam para o mesmo server⚠️ Armadilha 3: Comparar latência de InMemoryTransport com produção
InMemoryTransport: <1ms overhead
stdio local: 1-5ms overhead
HTTP localhost: 5-20ms overhead
HTTP remoto: 50-200ms overheadSe latência importa, teste com o transport que você usará em produção.
Se não for realizar o laboratório, pule para o próximo capítulo.
Ponte para o Lab
O starter implementa a Abordagem 1 (tool use direto) completamente e deixa um TODO para a Abordagem 2 (MCP):
TODO — Em abordagemMCP(): implemente o mesmo agent loop usando MCP client + server. A função criarAcoesServer() já existe no starter — você precisa: (1) criar o client, (2) conectar via InMemoryTransport, (3) listar tools e converter para formato Anthropic, (4) rodar o agent loop. O loop é idêntico ao da Abordagem 1 — só muda como você executa a tool: mcpClient.callTool() em vez de executarTool() local. Seção de referência: Aprofundamento Técnico → “Abordagem MCP via InMemoryTransport”.
Após implementar, o main() roda a mesma query nas duas abordagens e compara respostas e latência.
Agora você está pronto para o lab.