mozak.tech Engenharia de IA Corporativa 47%

Parte I — Alicerces da Inteligência Artificial Moderna

1.8 — Construindo Integrações Padronizadas para Modelos de Linguagem (MCP)

Objetivo da Aula

Explicar o problema que o MCP resolve e por que a solução de “function calling direto” não escala

Implementar um cliente MCP em TypeScript que conecta a um servidor, lista tools e executa chamadas

Distinguir os três tipos de capability do MCP: tools, resources e prompts

Integrar o fluxo MCP → Claude → resposta final em um pipeline coeso

Avaliar quando usar MCP vs function calling direto com critérios técnicos

Por que isso importa

Antes do MCP (pre-outubro 2024), cada integração de LLM com ferramenta externa era customizada. Você queria que Claude acessasse sua API de CRM? Implementava um wrapper específico. Queria que o Cursor acessasse seu banco de dados? Outro wrapper. 20 ferramentas = 20 implementações customizadas, incompatíveis entre si.

MCP resolveu isso com um protocolo padrão. O impacto foi imediato: em 6 meses de adoção, OpenAI, Google, Microsoft, Cursor, Windsurf, Replit e dezenas de outras empresas adotaram MCP. Um servidor MCP escrito uma vez funciona com todos esses clientes.

O que isso significa para você em produção: - Você escreve um servidor MCP para o sistema de busca interno da empresa - Qualquer dev do time usa Claude Desktop, Cursor, ou seu app customizado com esse servidor - Zero código de integração específico por cliente

Analogia: MCP é para ferramentas de LLM o que LSP (Language Server Protocol) é para editores. O LSP foi criado pela Microsoft em 2016 para que você não precisasse escrever um plugin de autocomplete específico para cada editor. Hoje, um language server para Go funciona no VSCode, Vim, Emacs, Zed. MCP faz o mesmo para ferramentas de IA.

Conceitos Fundamentais

A arquitetura MCP: três camadas

┌─────────────────────────────────────────────────────────┐

│                         HOST                            │

│  (Claude Desktop, Cursor, seu app customizado)          │

│                                                         │

│  ┌──────────────┐         ┌──────────────────────────┐ │

│  │   MCP Client  │◄──────►│  LLM (Claude, GPT, etc.) │ │

│  └──────────────┘         └──────────────────────────┘ │

│          │                                              │

└──────────┼──────────────────────────────────────────────┘

           │ protocolo MCP (JSON-RPC 2.0)

           │

     ┌─────┴───────┐

     │  MCP Server  │

     │              │

     │  - Tools      │

     │  - Resources  │

     │  - Prompts    │

     └──────────────┘

Host: o ambiente que contém o cliente MCP e o LLM. É o orchestrator.

Client: biblioteca que implementa o protocolo MCP e se comunica com o server.

Server: expõe capabilities (tools, resources, prompts) sobre qualquer transporte.

Os três tipos de capability

Tools: funções que o LLM pode chamar para executar ações

{

  "name": "get_weather",

  "description": "Retorna previsão do tempo para uma cidade brasileira",

  "inputSchema": {

    "type": "object",

    "properties": {

      "cidade": { "type": "string", "description": "Nome da cidade" },

      "dias": { "type": "number", "description": "Dias de previsão (1-7)" }

    },

    "required": ["cidade"]

  }

}

Tools são para AÇÕES — buscar dados, executar código, chamar APIs, modificar estado. O LLM decide quando e como chamar tools baseado na conversa.

Resources: dados que podem ser lidos pelo cliente

{

  "uri": "file:///logs/app.log",

  "name": "Application Log",

  "mimeType": "text/plain"

}

Resources são para DADOS ESTÁTICOS ou quasi-estáticos — arquivos, documentos, bases de conhecimento. O host pode injetar resources no contexto sem o LLM precisar “chamar” nada.

Prompts: templates de prompt pré-configurados

{

  "name": "analyze-code",

  "description": "Analisa código e sugere melhorias",

  "arguments": [

    { "name": "language", "description": "Linguagem de programação" }

  ]

}

Prompts são templates reutilizáveis que o host pode usar para criar mensagens padronizadas.

Transporte: como client e server se comunicam

MCP suporta dois transportes:

stdio (standard input/output): server e client se comunicam via stdin/stdout. Simples, sem porta de rede, ideal para desenvolvimento local e servidores que rodam como subprocessos.

Host → spawn processo do server

Host → escreve JSON-RPC no stdin do server

Server → lê stdin, processa, escreve resposta no stdout

Host → lê stdout do server

HTTP + SSE (Server-Sent Events): server exposto como endpoint HTTP. Necessário para: - Múltiplos clientes acessando o mesmo servidor simultaneamente - Servidor em máquina remota ou container - Casos onde subprocess não é viável

O ciclo de vida de uma chamada de tool

1. Host inicia: client.connect(transport)

   → Server confirma conexão e negocia capabilities



2. Host lista tools: client.listTools()

   → Server retorna array de tools disponíveis



3. Host passa tools para o LLM como ferramentas disponíveis

   → LLM decide quais tools chamar baseado na mensagem do usuário



4. LLM retorna tool_use block:

   { "type": "tool_use", "name": "get_weather", "input": {"cidade": "São Paulo"} }



5. Host executa via MCP: client.callTool("get_weather", { cidade: "São Paulo" })

   → Server executa, retorna resultado



6. Host adiciona tool_result ao histórico de mensagens

   → LLM gera resposta final usando o resultado da tool

Aprofundamento Técnico

JSON-RPC 2.0: o protocolo subjacente

Fundamento: MCP — JSON-RPC 2.0 e Transportes

JSON-RPC 2.0 é um protocolo de chamada remota sobre qualquer transporte: cada mensagem tem {"method": "tools/call", "params": {...}, "id": 1}. Menos comum que REST, mas mais próximo de gRPC na semântica. O MCP tem dois transportes: stdio (local) — o servidor MCP roda como subprocesso, comunicação via stdin/stdout sem porta de rede; e HTTP + SSE (remoto) — o servidor expõe endpoints HTTP, com Server-Sent Events para respostas assíncronas. O modelo de capabilities tem três tipos: tools (ações que o modelo pode invocar), resources (dados que o modelo pode ler — análogo a GET) e prompts (templates pré-configurados). Você define quais capabilities expõe; o modelo descobre via protocol de inicialização.

MCP usa JSON-RPC 2.0 como protocolo de mensagens. Cada mensagem tem a forma:

// Request (do client para o server):

{

  "jsonrpc": "2.0",

  "id": 1,

  "method": "tools/call",

  "params": {

    "name": "get_weather",

    "arguments": { "cidade": "São Paulo" }

  }

}



// Response (do server para o client):

{

  "jsonrpc": "2.0",

  "id": 1,

  "result": {

    "content": [

      { "type": "text", "text": "São Paulo: 23°C, parcialmente nublado" }

    ]

  }

}

O id correlaciona request com response — necessário para múltiplos requests simultâneos. O protocolo suporta também notificações (sem id), usadas para eventos unidirecionais do server para o client.

InMemoryTransport: como testar sem processo separado

O SDK do MCP tem um InMemoryTransport que permite client e server se comunicarem em memória, sem criar processos ou sockets. Perfeito para testes e para demonstrações didáticas como esta unidade:

import { Client } from '@modelcontextprotocol/sdk/client/index.js'

import { Server } from '@modelcontextprotocol/sdk/server/index.js'

import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'



// Cria um par de transportes conectados entre si

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()



// Server usa serverTransport

const server = criarServer()

await server.connect(serverTransport)



// Client usa clientTransport

const client = new Client({ name: 'meu-client', version: '1.0.0' }, { capabilities: {} })

await client.connect(clientTransport)



// Agora client e server se comunicam em memória

const tools = await client.listTools()

Em produção, você substituiria InMemoryTransport por StdioClientTransport (para subprocesso) ou SSEClientTransport (para HTTP).

Validação de schema de input: por que é crítico

Decisão de Arquitetura: Tool Calls como Superfície de Ataque

Quando uma tool do agente chama seu banco de dados, API interna, ou sistema de arquivos, o input não vem de um usuário — vem do LLM. Mas o LLM pode ser manipulado. Prompt injection acontece quando dados externos (resultado de uma busca, conteúdo de um arquivo lido pelo agente) contêm instruções para o modelo que o fazem desviar do objetivo. A tool call resultante pode executar operações destrutivas ou vazar dados. Defesa: (1) valide estritamente os parâmetros da tool contra um schema antes de executar — nunca confie no input do LLM; (2) use allowlist de ações destrutivas com aprovação humana; (3) aplique menor privilégio: a tool só pode fazer o que a tarefa exige. Trate tool calls como você trata input de usuário não autenticado.

Quando um LLM chama uma tool, o input vem do modelo — e modelos podem gerar inputs malformados, fora do range esperado, ou com campos faltando. O servidor MCP DEVE validar contra o schema antes de executar:

server.setRequestHandler(CallToolRequestSchema, async (request) => {

  const { name, arguments: args } = request.params

  

  if (name === 'get_weather') {

    // Validação manual — em produção use zod ou ajv

    if (!args?.cidade || typeof args.cidade !== 'string') {

      throw new Error('cidade é obrigatório e deve ser string')

    }

    if (args.dias !== undefined && (args.dias < 1 || args.dias > 7)) {

      throw new Error('dias deve estar entre 1 e 7')

    }

    

    // Só aqui executamos a lógica real

    return await buscarPrevisao(args.cidade, args.dias ?? 3)

  }

})

Sem validação, um LLM “criativo” pode passar dias: 9999 ou cidade: null e causar comportamentos inesperados no sistema externo que a tool está integrando.

Exemplos Anotados

Exemplo 1: Servidor MCP com duas tools

import { Server } from '@modelcontextprotocol/sdk/server/index.js'

import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'



function criarWeatherServer(): Server {

  const server = new Server(

    // Identificação do server — usada pelo client para logging e debugging

    { name: 'weather-server', version: '1.0.0' },

    { capabilities: { tools: {} } }  // declaramos que este server expõe tools

  )



  // Handler para listar as tools disponíveis

  // O client (e o LLM) chama isso primeiro para descobrir o que pode fazer

  server.setRequestHandler(ListToolsRequestSchema, async () => ({

    tools: [

      {

        name: 'get_weather',

        description: 'Retorna previsão do tempo para uma cidade brasileira',

        inputSchema: {

          type: 'object',

          properties: {

            cidade: { type: 'string', description: 'Nome da cidade' },

            dias: { type: 'number', description: 'Dias de previsão (1-7)', default: 3 },

          },

          required: ['cidade'],  // garante que LLM sempre forneça cidade

        },

      },

    ],

  }))



  // Handler para executar chamadas de tool

  // Chamado quando o LLM decide usar uma das tools listadas acima

  server.setRequestHandler(CallToolRequestSchema, async (request) => {

    const { name, arguments: args } = request.params



    if (name === 'get_weather') {

      const cidade = args?.cidade as string

      const dias = (args?.dias as number) ?? 3



      // Dados mock — em produção, chame API real de clima

      // O formato de retorno: content é array de blocos, cada um com type e text/data

      return {

        content: [

          {

            type: 'text',

            text: JSON.stringify({

              cidade,

              previsao: Array.from({ length: dias }, (_, i) => ({

                dia: i + 1,

                temperatura: `${20 + Math.round(Math.random() * 10)}°C`,

                condicao: ['Ensolarado', 'Nublado', 'Chuvoso'][Math.floor(Math.random() * 3)],

              })),

            }),

          },

        ],

      }

    }



    // Throw para tools não reconhecidas — evita falha silenciosa

    throw new Error(`Tool desconhecida: ${name}`)

  })



  return server

}
Fundamento: Laço de Tool Use

A API de LLM não executa ferramentas — ela retorna a intenção de chamá-las. O ciclo é: (1) você chama a API; (2) se stop_reason === 'tool_use', o modelo quer executar uma ferramenta; (3) sua aplicação executa a ferramenta; (4) você devolve o resultado com tool_result correlacionado pelo tool_use_id; (5) chama a API de novo. Esse ping-pong continua até stop_reason === 'end_turn'. A API é completamente stateless — todo o histórico de mensagens (incluindo os blocos tool_use e tool_result) deve ser enviado em cada requisição. O tool_use_id é o correlator que permite ao modelo saber qual resultado corresponde a qual chamada — crítico em chamadas paralelas.

Exemplo 2: Pipeline completo Client → MCP → Claude

import Anthropic from '@anthropic-ai/sdk'

import { Client } from '@modelcontextprotocol/sdk/client/index.js'

import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'



async function perguntarComMCP(pergunta: string): Promise<string> {

  const anthropic = new Anthropic()



  // 1. Inicializar client MCP

  const client = new Client({ name: 'meu-app', version: '1.0.0' }, { capabilities: {} })

  const server = criarWeatherServer()

  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()

  await server.connect(serverTransport)

  await client.connect(clientTransport)



  // 2. Listar tools disponíveis no server

  // Convertemos para o formato que a API Anthropic espera

  const toolsResponse = await client.listTools()

  const toolsAnthropic = toolsResponse.tools.map((tool) => ({

    name: tool.name,

    description: tool.description,

    input_schema: tool.inputSchema,

  }))



  // 3. Primeira chamada ao Claude com as tools disponíveis

  const mensagens = [{ role: 'user' as const, content: pergunta }]

  let response = await anthropic.messages.create({

    model: 'claude-haiku-4-5-20251001',

    max_tokens: 1000,

    tools: toolsAnthropic,

    messages: mensagens,

  })



  // 4. Loop: enquanto Claude quiser usar tools, executamos via MCP

  while (response.stop_reason === 'tool_use') {

    const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use')



    // Adicionamos a resposta de Claude (com os tool_use blocks) ao histórico

    mensagens.push({ role: 'assistant', content: response.content })



    // Executamos todas as tools chamadas e coletamos resultados

    const toolResults = await Promise.all(

      toolUseBlocks.map(async (block) => {

        if (block.type !== 'tool_use') return null

        const resultado = await client.callTool({ name: block.name, arguments: block.input })

        return {

          type: 'tool_result' as const,

          tool_use_id: block.id,

          content: resultado.content[0].type === 'text' ? resultado.content[0].text : '',

        }

      })

    )



    // Adicionamos resultados ao histórico e chamamos Claude novamente

    mensagens.push({ role: 'user', content: toolResults.filter(Boolean) })

    response = await anthropic.messages.create({

      model: 'claude-haiku-4-5-20251001',

      max_tokens: 1000,

      tools: toolsAnthropic,

      messages: mensagens,

    })

  }



  // 5. Extraímos o texto final da resposta

  const textoFinal = response.content.find((b) => b.type === 'text')

  return textoFinal?.type === 'text' ? textoFinal.text : 'Sem resposta'

}
Como o arquiteto lê este código

O código executa múltiplas chamadas de ferramenta em paralelo. Promise.all([...]) recebe um array de promessas e espera todas completarem — análogo a disparar N requests HTTP em paralelo e aguardar todas as respostas antes de continuar. O .map(async (block) => {...}) transforma cada bloco de tool_use em uma promessa de resultado. O await dentro do map é resolvido em paralelo porque o Promise.all gerencia as N promessas simultaneamente. Se qualquer uma rejeitar (lançar erro), o Promise.all rejeita com o primeiro erro — use Promise.allSettled se quiser tolerância a falhas individuais.

Padrões e Armadilhas

Padrões recomendados

Padrão 1: Descrições de tool detalhadas e específicas A description de cada tool é o que o LLM usa para decidir quando e como chamar. Descrições vagas produzem uso incorreto. “Busca dados” é ruim; “Retorna previsão do tempo para cidade brasileira nos próximos 1-7 dias com temperatura e condição” é bom.

Padrão 2: Retorne erros como content, não como throw Em tool handlers, erros de negócio (cidade não encontrada, API de terceiro fora do ar) devem retornar como content: [{ type: "text", text: "Cidade X não encontrada" }] em vez de throw. O LLM pode lidar com erros de negócio graciosamente; throws causam falha do pipeline.

Padrão 3: Valide inputs de tool antes de executar Todo input que vem do LLM deve ser validado antes de passar para a lógica real. Use Zod no TypeScript: const input = GetWeatherInput.parse(args) — lança erro com mensagem clara se inválido.

Armadilhas comuns

⚠️ Armadilha 1: Não tratar stop_reason === 'tool_use' em loop O que acontece: Claude pode querer chamar 3 tools em sequência. Se você só executa a primeira e não faz o loop, as demais não são executadas. Versão correta: while (response.stop_reason === 'tool_use') { ... } — sempre loop até stop_reason === 'end_turn'.

⚠️ Armadilha 2: Passar tools ao Claude sem o server MCP disponível O que acontece: Claude decide chamar a tool, mas o servidor MCP já foi desconectado ou nunca foi inicializado. Você tem um tool_use block mas não tem como executá-lo. Versão correta: sempre inicialize e valide a conexão com o server ANTES de passar tools para o Claude. Falhe rápido se o server não está disponível.

⚠️ Armadilha 3: Esquecer o tool_use_id no tool_result O que acontece: Claude retornou múltiplos tool_use blocks com IDs diferentes. Se você não mapear o tool_use_id correto para cada tool_result, Claude não consegue correlacionar resultado com chamada e pode re-chamar tools ou falhar. Versão correta: tool_use_id: block.id — o ID deve ser o mesmo que veio no tool_use block.

⚗ Laboratório prático — mozak.tech
Se não for realizar o laboratório, pule para o próximo capítulo.

Ponte para o Lab

O starter tem 2 TODOs principais:

TODO 1 — Chamar a tool via MCP e processar resultado Seção de referência: “Exemplos Anotados → Exemplo 2 → Passo 4”. No loop de tool_use, para cada bloco de tool use:

const resultado = await mcpClient.callTool({ name: block.name, arguments: block.input })

O resultado tem content[0].text com o JSON da resposta do server.

TODO 2 — Integrar resultado da tool no loop de mensagens para Claude Seção de referência: “Conceitos Fundamentais → O ciclo de vida de uma chamada de tool → Passos 4-6”. Após executar a tool, adicione ao histórico:

mensagens.push({ role: 'assistant', content: response.content })

mensagens.push({ role: 'user', content: [{ type: 'tool_result', tool_use_id: block.id, content: resultado }] })

Depois chame anthropic.messages.create() novamente com o histórico atualizado.

Dica para o TODO mais difícil (TODO 2): o formato exato das mensagens de tool_result é rigoroso. Qualquer campo fora do spec causa erro 400 da API. Veja o Exemplo 2 com atenção — especialmente type: 'tool_result' e tool_use_id.

Agora você está pronto para o lab.