mozak.tech Engenharia de IA Corporativa 11%

Parte I — Conectividade Padronizada entre Sistemas e Modelos

1.4 — Servidor MCP em TypeScript: Ferramentas, Recursos e Prompts (MCP Server)

Objetivo da Aula

Ao concluir esta aula, você será capaz de:

Implementar as 3 primitivas do MCP em um server TypeScript: tools, resources e prompts

Adicionar uma nova tool a um server existente seguindo o padrão de handlers duplos (ListTools + CallTool)

Estruturar um server MCP de produção com separação clara de responsabilidades

Testar um server MCP usando InMemoryTransport e client de linha de comando

Entender o ciclo completo de uma tool call: client → server → execução → resultado

Por que isso importa

As Unidades 02 e 03 mostraram como consumir tools via MCP. Esta unidade te ensina a criar o server — o outro lado da equação.

Um servidor MCP bem projetado é a peça que permite que qualquer equipe consuma seus dados e operações via qualquer cliente LLM. Você implementa uma vez e o Claude Desktop, Cursor, Windsurf, e seu próprio agente podem todos usar sem nenhuma mudança no server.

O template que você vai criar aqui — server com tools + resources + prompts — é o template de referência para todos os servers que você criar no futuro. As 3 primitivas cobrem 95% dos casos de uso de integração LLM.

Conceitos Fundamentais

Estrutura de um MCP Server TypeScript

Todo server MCP em TypeScript tem a mesma estrutura:

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

import {

  ListToolsRequestSchema,    // GET /tools/list

  CallToolRequestSchema,     // POST /tools/call

  ListResourcesRequestSchema, // GET /resources/list

  ReadResourceRequestSchema,  // GET /resources/read

  ListPromptsRequestSchema,   // GET /prompts/list

  GetPromptRequestSchema,     // GET /prompts/get

} from '@modelcontextprotocol/sdk/types.js'



// 1. Declara capabilities que o server suporta

const server = new Server(

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

  {

    capabilities: {

      tools: {},       // declara suporte a tools

      resources: {},   // declara suporte a resources

      prompts: {},     // declara suporte a prompts

    }

  }

)



// 2. Registra handler para LISTAR tools

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

  tools: [/* array de ToolDefinition */]

}))



// 3. Registra handler para EXECUTAR tools

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

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

  // executa a tool e retorna resultado

  return { content: [{ type: 'text', text: 'resultado' }] }

})



// Idem para resources e prompts...



// 4. Conecta ao transport

await server.connect(transport)

Por que dois handlers por primitiva? O protocolo MCP separa “descoberta” (o que existe?) de “execução” (faça isso). Isso permite que clients façam discovery antes de decidir o que usar.

Tools: Ações com Schema

Uma tool definition tem 3 campos obrigatórios:

{

  name: 'criar_tarefa',              // identificador único no server

  description: 'Cria uma nova tarefa. Use quando o usuário pedir para adicionar, criar ou registrar uma tarefa.',  // para o LLM decidir quando usar

  inputSchema: {                     // JSON Schema dos parâmetros

    type: 'object',

    properties: {

      titulo: {

        type: 'string',

        description: 'Título curto e descritivo da tarefa',

      },

      prioridade: {

        type: 'string',

        enum: ['baixa', 'media', 'alta'],

        description: 'Nível de prioridade da tarefa',

      },

    },

    required: ['titulo', 'prioridade'],  // campos obrigatórios

  },

}

O inputSchema segue o JSON Schema draft 7. Os campos mais usados: - type: ‘string’, ‘number’, ‘boolean’, ‘object’, ‘array’ - enum: lista de valores válidos - description: descrição do campo para o LLM - required: array de nomes de campos obrigatórios - minimum/maximum: limites numéricos - minLength/maxLength: limites de string

Resources: Dados para Leitura

Resources são URIs que o host pode buscar sob demanda. Úteis para documentação, configurações, schemas:

// Listar resources disponíveis

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

  resources: [

    {

      uri: 'tarefas://lista',          // URI identificador

      name: 'Lista de tarefas',

      description: 'Todas as tarefas cadastradas em JSON',

      mimeType: 'application/json',   // tipo do conteúdo

    },

    {

      uri: 'tarefas://resumo',

      name: 'Resumo de tarefas',

      description: 'Contagem por status e prioridade',

      mimeType: 'text/plain',

    },

  ]

}))



// Ler o conteúdo de um resource

server.setRequestHandler(ReadResourceRequestSchema, async (req) => {

  const uri = req.params.uri



  if (uri === 'tarefas://lista') {

    return {

      contents: [{

        uri,

        mimeType: 'application/json',

        text: JSON.stringify(tarefas, null, 2),

      }]

    }

  }



  throw new Error(`Resource não encontrado: ${uri}`)

})

Prompts: Templates Reutilizáveis

Prompts no MCP são templates que o host pode buscar e usar:

// Listar prompts disponíveis

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

  prompts: [

    {

      name: 'resumo_diario',

      description: 'Gera um prompt para resumir as tarefas do dia',

      arguments: [

        { name: 'data', description: 'Data no formato DD/MM/AAAA', required: false },

      ],

    },

  ]

}))



// Retornar o conteúdo de um prompt específico

server.setRequestHandler(GetPromptRequestSchema, async (req) => {

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



  if (name === 'resumo_diario') {

    const data = args?.data ?? new Date().toLocaleDateString('pt-BR')

    const tarefasPendentes = tarefas.filter(t => t.status === 'pendente')



    return {

      description: 'Prompt para resumo diário de tarefas',

      messages: [

        {

          role: 'user',

          content: {

            type: 'text',

            text: `Resuma as tarefas pendentes para ${data}:\n\n${JSON.stringify(tarefasPendentes, null, 2)}`,

          }

        }

      ]

    }

  }



  throw new Error(`Prompt não encontrado: ${name}`)

})

Retorno de Erro em Tools

Quando uma tool falha, retorne com isError: true:

// Erro recuperável — o LLM pode tentar novamente ou adaptar

return {

  content: [{ type: 'text', text: `Tarefa ${id} não encontrada` }],

  isError: true,

}



// Sucesso

return {

  content: [{ type: 'text', text: JSON.stringify(tarefa) }],

}

Com isError: true, o LLM vê o erro como resultado da tool e pode ajustar sua próxima ação — por exemplo, listar as tarefas disponíveis antes de tentar buscar uma específica.

Aprofundamento Técnico

Adicionando Uma Nova Tool: O Padrão

Adicionar uma nova tool requer dois passos no mesmo server:

Passo 1: Declarar no ListTools handler

// No handler ListToolsRequestSchema, adicione à lista:

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

  tools: [

    // ... tools existentes ...

    {

      name: 'atualizar_status',             // NOVA TOOL

      description: 'Atualiza o status de uma tarefa existente',

      inputSchema: {

        type: 'object',

        properties: {

          id: { type: 'number', description: 'ID da tarefa' },

          status: {

            type: 'string',

            enum: ['pendente', 'em_progresso', 'concluida'],

            description: 'Novo status da tarefa',

          },

        },

        required: ['id', 'status'],

      },

    },

  ],

}))

Passo 2: Implementar no CallTool handler

// No handler CallToolRequestSchema, adicione o case:

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

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



  // ... cases existentes ...



  if (name === 'atualizar_status') {            // NOVA IMPLEMENTAÇÃO

    const { id, status } = args as { id: number; status: Tarefa['status'] }

    const tarefa = tarefas.find(t => t.id === id)



    if (!tarefa) {

      return {

        content: [{ type: 'text', text: `Tarefa ${id} não encontrada` }],

        isError: true,

      }

    }



    const statusAnterior = tarefa.status

    tarefa.status = status



    return {

      content: [{

        type: 'text',

        text: JSON.stringify({

          sucesso: true,

          id,

          status_anterior: statusAnterior,

          status_novo: status,

        }),

      }],

    }

  }



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

})

A regra: se você declarou a tool no ListTools mas esqueceu de implementar no CallTool, o LLM tentará chamar e receberá throw new Error('Tool desconhecida') — que aparece como erro no tool_result.

Tipagem TypeScript para Args

O args em CallTool é Record<string, unknown> por padrão. Para typagem segura:

// UNSAFE: sem validação de tipo

const { id, status } = args as { id: number; status: string }



// SAFE com Zod:

import { z } from 'zod'



const AtualizarStatusSchema = z.object({

  id: z.number().int().positive(),

  status: z.enum(['pendente', 'em_progresso', 'concluida']),

})



// No handler:

const { id, status } = AtualizarStatusSchema.parse(args)

// Lança ZodError se args não bater com o schema — erro detalhado

Usar Zod é recomendado para servers de produção — garante que mesmo se o LLM enviar args malformados, você recebe um erro descritivo em vez de comportamento inesperado.

Estado em Memória vs Persistência

O server do starter usa arrays em memória (const tarefas: Tarefa[] = []). Isso é simples para aprender, mas tem limitações:

Estado em memória: - Reiniciar o server limpa todos os dados - Não compartilha estado entre múltiplas instâncias do server - Adequado para: demos, labs, state efêmero

Persistência real: para produção, use banco de dados:

// Substitua arrays por queries ao banco

import { db } from './db'



// Em vez de: tarefas.push(novaTarefa)

await db.query('INSERT INTO tarefas (titulo, prioridade) VALUES (?, ?)', [titulo, prioridade])



// Em vez de: tarefas.find(t => t.id === id)

const tarefa = await db.query('SELECT * FROM tarefas WHERE id = ?', [id])

Exemplos Anotados

Exemplo 1: Server Completo de Tarefas

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

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

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

import {

  CallToolRequestSchema, GetPromptRequestSchema,

  ListPromptsRequestSchema, ListResourcesRequestSchema,

  ListToolsRequestSchema, ReadResourceRequestSchema,

} from '@modelcontextprotocol/sdk/types.js'



interface Tarefa {

  id: number

  titulo: string

  prioridade: 'baixa' | 'media' | 'alta'

  status: 'pendente' | 'em_progresso' | 'concluida'

}



let nextId = 1

const tarefas: Tarefa[] = []



function criarTarefasServer(): Server {

  const server = new Server(

    { name: 'gerenciador-tarefas', version: '1.0.0' },

    { capabilities: { tools: {}, resources: {}, prompts: {} } }

  )



  // === TOOLS ===

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

    tools: [

      {

        name: 'criar_tarefa',

        description: 'Cria uma nova tarefa na lista',

        inputSchema: {

          type: 'object',

          properties: {

            titulo: { type: 'string' },

            prioridade: { type: 'string', enum: ['baixa', 'media', 'alta'] },

          },

          required: ['titulo', 'prioridade'],

        },

      },

      {

        name: 'listar_tarefas',

        description: 'Lista tarefas, opcionalmente filtradas por status',

        inputSchema: {

          type: 'object',

          properties: {

            status: { type: 'string', enum: ['pendente', 'em_progresso', 'concluida', 'todas'] },

          },

        },

      },

      // TODO 1: Adicione a tool 'atualizar_status' aqui

      // campos: id (number), status ('pendente'|'em_progresso'|'concluida')

    ],

  }))



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

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



    if (name === 'criar_tarefa') {

      const { titulo, prioridade } = args as { titulo: string; prioridade: Tarefa['prioridade'] }

      const nova: Tarefa = { id: nextId++, titulo, prioridade, status: 'pendente' }

      tarefas.push(nova)

      return { content: [{ type: 'text', text: JSON.stringify(nova) }] }

    }



    if (name === 'listar_tarefas') {

      const { status = 'todas' } = (args as { status?: string }) ?? {}

      const filtradas = status === 'todas' ? tarefas : tarefas.filter(t => t.status === status)

      return {

        content: [{ type: 'text', text: JSON.stringify({ total: filtradas.length, tarefas: filtradas }) }]

      }

    }



    // TODO 2: Implemente o handler para 'atualizar_status'

    // - Encontra a tarefa pelo id em `tarefas`

    // - Se não encontrar: retorna isError: true

    // - Se encontrar: atualiza tarefa.status e retorna sucesso com status anterior e novo



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

  })



  // === RESOURCES ===

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

    resources: [{

      uri: 'tarefas://todas',

      name: 'Todas as tarefas',

      mimeType: 'application/json',

    }],

  }))



  server.setRequestHandler(ReadResourceRequestSchema, async (req) => ({

    contents: [{

      uri: req.params.uri,

      mimeType: 'application/json',

      text: JSON.stringify(tarefas, null, 2),

    }]

  }))



  // === PROMPTS ===

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

    prompts: [{

      name: 'planejar_dia',

      description: 'Cria prompt para planejar tarefas do dia',

      arguments: [],

    }],

  }))



  server.setRequestHandler(GetPromptRequestSchema, async (req) => {

    if (req.params.name !== 'planejar_dia') throw new Error('Prompt não encontrado')

    const pendentes = tarefas.filter(t => t.status === 'pendente')

    return {

      messages: [{

        role: 'user',

        content: {

          type: 'text',

          text: `Tenho ${pendentes.length} tarefas pendentes. Quais devo priorizar hoje?\n\n${JSON.stringify(pendentes, null, 2)}`,

        }

      }]

    }

  })



  return server

}



// Teste via client

async function testar() {

  const server = criarTarefasServer()

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

  const [ct, st] = InMemoryTransport.createLinkedPair()

  await server.connect(st)

  await client.connect(ct)



  // Cria tarefas

  await client.callTool({ name: 'criar_tarefa', arguments: { titulo: 'Estudar MCP', prioridade: 'alta' } })

  await client.callTool({ name: 'criar_tarefa', arguments: { titulo: 'Revisar PR', prioridade: 'media' } })



  // Lista todas

  const lista = await client.callTool({ name: 'listar_tarefas', arguments: { status: 'todas' } })

  console.log('Tarefas:', lista.content[0].text)



  // TODO: Testa atualizar_status quando implementada

  // await client.callTool({ name: 'atualizar_status', arguments: { id: 1, status: 'em_progresso' } })



  // Resources

  const resource = await client.readResource({ uri: 'tarefas://todas' })

  console.log('Resource:', resource.contents[0].text)



  // Prompts

  const prompt = await client.getPrompt({ name: 'planejar_dia', arguments: {} })

  console.log('Prompt:', prompt.messages[0].content.text)

}



testar().catch(console.error)

Padrões e Armadilhas

Padrões

Padrão 1: Declare capability antes de registrar handler

// ERRADO: sem declarar resources na capability mas registrando handler

const server = new Server({ name: '...' }, { capabilities: { tools: {} } })  // sem resources!

server.setRequestHandler(ListResourcesRequestSchema, ...)  // handler registrado mas client não sabe que existe

Padrão 2: Tools para mutação, Resources para leitura frequente sem args

Tool 'listar_tarefas' com args: quando você precisa filtrar/ordenar

Resource 'tarefas://todas': quando o client só quer todos os dados, sem filtros

Se a leitura precisa de parâmetros, use tool. Se é sempre o mesmo dado, use resource.

Padrão 3: Sempre implemente o case de erro em CallTool

// Ao final do handler, sempre lance erro para tools desconhecidas

// (não retorne silenciosamente)

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

Isso garante que erros de nome de tool são detectados imediatamente, não silenciados.

Armadilhas

⚠️ Armadilha 1: Declarar tool no ListTools mas não implementar no CallTool O LLM vai chamar a tool, o server vai lançar throw new Error('Tool desconhecida'), e o LLM vai receber um tool_result de erro. Diagnóstico: você declarou mas não implementou. Sem exceção, os dois handlers devem ficar em sincronia.

⚠️ Armadilha 2: args como any sem validação

// UNSAFE: se LLM enviar id como string, crash em runtime

const { id, status } = args as { id: number; status: string }

tarefas.find(t => t.id === id)  // undefined se id for string "1" vs number 1



// SAFE: converta explicitamente

const id = Number(args.id)

if (isNaN(id)) return { content: [{ type: 'text', text: 'ID inválido' }], isError: true }
⚗ 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 que completam a tool atualizar_status:

TODO 1 — No ListToolsRequestSchema handler: adicione a definição da tool atualizar_status à lista de tools. O inputSchema deve ter dois campos obrigatórios: id (number, “ID da tarefa a atualizar”) e status (string com enum dos 3 valores possíveis). Seção de referência: Conceitos Fundamentais → “Tools: Ações com Schema”.

TODO 2 — No CallToolRequestSchema handler: adicione o case if (name === 'atualizar_status'). Cast os args, encontre a tarefa com tarefas.find(t => t.id === id), retorne isError: true se não encontrar, caso contrário atualize tarefa.status e retorne JSON com {sucesso: true, id, status_anterior, status_novo}. Seção de referência: Aprofundamento Técnico → “Adicionando Uma Nova Tool: O Padrão”.

O starter já tem um comentário // TODO: Teste a tool atualizar_status quando implementada no main — descomente para testá-la após implementar.

Agora você está pronto para o lab.