Skip to content

Repository files navigation

SmartGIL - Converse com seu banco de dados em linguagem natural

Workshop prático: construindo um servidor MCP integrado a um banco de dados PostgreSQL

Node.js TypeScript PostgreSQL Podman Gemini MCP

Neste workshop, você vai construir do zero um servidor MCP (Model Context Protocol) que permite que modelos de linguagem como Claude respondam perguntas diretamente sobre os dados de um banco PostgreSQL. Em vez de escrever SQL manualmente, você faz uma pergunta em português - e o sistema encontra as tabelas certas, gera a query e retorna a resposta.

Exemplo real:

"Quais professores têm horário livre na quarta-feira à tarde?"

→ SmartGIL encontra as tabelas certas, gera o SQL correto e responde em linguagem natural.


Tecnologias utilizadas

Linguagens, frameworks e ferramentas utilizadas na construção do projeto.

Tecnologia Papel no projeto
TypeScript Linguagem principal do servidor MCP
Express.js Servidor HTTP que expõe o protocolo MCP via SSE
PostgreSQL Banco de dados relacional onde os dados ficam armazenados
ChromaDB Banco vetorial - armazena o schema indexado para busca semântica
Google Gemini API Gera embeddings do schema via gemini-embedding-2 (grátis, sem instalar)
Podman Runtime de containers (substituto ao Docker, sem daemon root)
@modelcontextprotocol/sdk SDK oficial MCP da Anthropic
Zod Validação e tipagem de schemas em tempo de execução

Onde Aplicar

Este projeto pode ser aplicado em diversas situações do mundo real:

  • Análise de dados interna - perguntas em linguagem natural sobre bancos de dados corporativos
  • Suporte e helpdesk - deixar que o suporte consulte o banco sem conhecer SQL
  • Exploração de dados - cientistas e analistas descobrem o que há no banco sem documentação prévia
  • Automação com IA - agentes que buscam, analisam e relatam dados autonomamente
  • Prototipagem de produtos - integrar LLMs a qualquer banco PostgreSQL existente em minutos

Sumário


Instalações

Siga com precisão as orientações de configuração do ambiente para garantir que o projeto funcione corretamente durante o workshop.

Podman (runtime de containers)

Podman 6.0+ é obrigatório - o comando podman compose vem embutido a partir desta versão.

Sistema Comando / Link
Ubuntu/Debian sudo apt install podman
Fedora/RHEL sudo dnf install podman
macOS brew install podmanpodman machine initpodman machine start
Windows Podman Desktop

Verifique a instalação:

podman --version      # deve ser 6.0+
podman compose version

Google Gemini API (embeddings)

O SmartGIL usa a API do Google Gemini para gerar os embeddings do schema do banco. É gratuita, não exige cartão de crédito e leva menos de 2 minutos para configurar.

Passo 1 - Acesse o Google AI Studio:

Abra aistudio.google.com e faça login com uma conta Google.

Passo 2 - Crie sua API Key:

  1. Clique em "Get API key" no menu lateral esquerdo
  2. Clique em "Create API key"
  3. Escolha um projeto Google Cloud existente (ou crie um novo - é gratuito)
  4. Copie a chave gerada (começa com AQ. — esse é o formato atual do Google AI Studio)

Passo 3 - Adicione a chave ao projeto:

# Copie o arquivo de exemplo
cp .env.example .env

# Edite o .env e substitua "sua_chave_aqui" pela sua chave real
# GEMINI_API_KEY=AIzaSy...

Free tier do Gemini: 1.500 requisições por dia e 100 por minuto para embeddings - mais do que suficiente para este workshop e projetos reais de pequeno porte.

Node.js 20+

Necessário apenas para o cliente CLI (ollama-client).

Baixe em nodejs.org ou use um gerenciador de versões como nvm:

nvm install 20
nvm use 20
node --version  # deve ser v20+

Claude Desktop (opcional, recomendado)

Para uma experiência completa de chat com interface gráfica. Baixe em claude.ai/download.

Recursos adicionais

  • DBeaver - cliente SQL para explorar o banco de dados visualmente (opcional)
  • Reqly ou Insomnia - para testar os endpoints HTTP do servidor e da API Gemini (opcional, mas muito útil)

Por que IA + Banco de Dados?

Imagine que você é analista em uma universidade e precisa responder perguntas sobre dados acadêmicos diariamente. Para cada pergunta, você precisaria:

  1. Conhecer a estrutura - saber que existem tabelas turmas, professores, horarios
  2. Escrever o SQL - com JOINs corretos, filtros, agregações
  3. Interpretar o resultado - transformar linhas em informação útil

Com MCP + LLM, você simplesmente pergunta em português. O sistema faz todo o resto.

🔌 O que é MCP?

MCP (Model Context Protocol) é um protocolo aberto criado pela Anthropic que padroniza como modelos de linguagem se comunicam com ferramentas e fontes de dados externas.

Antes do MCP, cada integração era proprietária e incompatível. Com MCP, existe um contrato comum:

┌─────────────────────────────┐
│  Cliente MCP                │
│  (Claude Desktop, etc.)     │
└────────────┬────────────────┘
             │ protocolo padronizado (SSE/HTTP)
┌────────────▼────────────────┐
│  Servidor MCP               │  ← o que vamos construir
│  (nosso mcp-server)         │
└────────────┬────────────────┘
             │
      ┌──────▼──────┐
      │  Ferramentas │
      │  (banco, API)│
      └─────────────┘

O servidor MCP expõe ferramentas - funções com nome e parâmetros que o LLM pode chamar. Quando você pergunta algo, o LLM decide automaticamente qual ferramenta chamar e com quais parâmetros.

🗄️ O que é um banco de dados relacional?

Um banco de dados relacional organiza informações em tabelas - estruturas com linhas (registros) e colunas (atributos). As tabelas se relacionam entre si através de chaves:

professores
┌────┬──────────────┬─────────────────┐
│ id │ nome         │ departamento_id  │
├────┼──────────────┼─────────────────┤
│  1 │ Ana Lima     │ 1               │──┐
│  2 │ Bruno Costa  │ 1               │  │
└────┴──────────────┴─────────────────┘  │
                                         │ FK (chave estrangeira)
departamentos                            │
┌────┬──────────────────┐                │
│ id │ nome             │                │
├────┼──────────────────┤                │
│  1 │ Computação       │◄───────────────┘
│  2 │ Matemática       │
└────┴──────────────────┘

Schema é a descrição completa dessa estrutura - quais tabelas existem, quais colunas cada uma tem, quais são os tipos e como elas se relacionam. O SmartGIL lê esse schema e o torna "buscável" pelo LLM.

SQL (Structured Query Language) é a linguagem para consultar bancos relacionais:

-- "Quais professores são do departamento de Computação?"
SELECT p.nome
FROM professores p
JOIN departamentos d ON d.id = p.departamento_id
WHERE d.nome = 'Computação e Matemática';

🔍 O que são embeddings e busca vetorial?

Um embedding é uma representação numérica de um texto - um vetor com centenas de números. Textos com significado similar ficam próximos no espaço vetorial:

"professores disponíveis na quarta"   → [0.23, -0.41, 0.87, 0.12, ...]
"docentes com horário livre quarta"   → [0.21, -0.39, 0.85, 0.14, ...]  ← próximo!
"número de matrículas em turmas"      → [-0.12, 0.67, -0.23, 0.55, ...] ← distante

O ChromaDB é um banco de dados vetorial - armazena os embeddings de cada tabela do schema e permite encontrar quais tabelas são relevantes para uma pergunta, mesmo que as palavras não sejam idênticas.

Pergunta: "professores livres na quarta"
    ↓ gera embedding da pergunta (via API Gemini)
ChromaDB compara com embeddings das tabelas
    ↓ encontra as mais próximas
Retorna: tabelas "professores" e "disponibilidade_professor"

🤖 O que são APIs de IA?

Uma API de IA (Application Programming Interface) é um serviço na nuvem que expõe capacidades de inteligência artificial via chamadas HTTP - sem precisar instalar nada localmente, sem GPU, sem gerenciar modelos.

Você envia um texto por HTTP e recebe de volta um resultado processado por um modelo de linguagem rodando em servidores especializados.

Seu servidor          Internet          Servidores Google
┌──────────┐   HTTP POST   ┌─────────────────────────────┐
│  SmartGIL │ ──────────► │  Gemini API                  │
│           │              │  modelo: gemini-embedding-2  │
│           │ ◄──────────  │  → retorna vetor [3072 nums] │
└──────────┘   JSON resp   └─────────────────────────────┘

Por que usar uma API de IA em vez de rodar localmente?

Aspecto API de nuvem (Gemini) Local (Ollama)
Instalação Nenhuma - só uma chave de API ~274 MB para baixar o modelo
Hardware Qualquer computador Requer RAM/GPU adequados
Velocidade Muito rápida (Google usa TPUs) Depende do hardware local
Custo Grátis até 1.500 req/dia Gratuito após instalar
Privacidade Dados passam pela Google Tudo local, nenhum dado externo
Offline Requer internet Funciona sem internet

Para este workshop, usamos a API Gemini por ser mais simples e funcionar em qualquer ambiente - não depende do hardware disponível.

O Ollama também é uma excelente opção para quem precisa de privacidade total ou trabalhar offline. Veja a seção Alternativa: Ollama (LLMs locais) ao final deste documento.


O que vamos construir

┌────────────────────────────────────────────────────────────────┐
│  Sua máquina                                                   │
│                                                                │
│  ┌──────────────────┐          ┌──────────────────────────┐   │
│  │  Claude Desktop  │          │  ollama-client (CLI)      │   │
│  │  (interface MCP) │          │  Node.js + Ollama         │   │
│  └────────┬─────────┘          └──────────┬───────────────┘   │
│           │                               │  MCP/SSE          │
└───────────┼───────────────────────────────┼───────────────────┘
            │ HTTP porta 3001               │
┌───────────▼───────────────────────────────▼───────────────────┐
│  Podman Compose                                                 │
│                                                                 │
│  ┌─────────────────────────────┐   ┌────────────────────────┐  │
│  │  mcp-server (porta 3001)    │   │  ChromaDB (porta 8000) │  │
│  │  Express + SSE              │◄──│  schema indexado        │  │
│  │  TypeScript                 │   │  busca vetorial         │  │
│  └──────────────┬──────────────┘   └────────────────────────┘  │
│                 │ pg (node-postgres)     ↑ embeddings           │
└─────────────────┼─────────────────────────────────────────────┘
                  │ TCP/IP          ┌──────────────────────┐
        ┌─────────▼────────────┐    │  Google Gemini API   │
        │  PostgreSQL           │    │  gemini-embedding-2  │
        │  postgres-demo:5432  │    │  (nuvem - grátis)    │
        │  (banco de dados)    │    └──────────────────────┘
        └──────────────────────┘

Ferramentas MCP que o servidor expõe

Ferramenta O que faz
conectar_banco Conecta ao PostgreSQL, lê o schema inteiro e indexa no ChromaDB
buscar_informacoes Busca semanticamente quais tabelas são relevantes para uma pergunta
executar_query Executa SQL no banco e retorna os resultados formatados
listar_schema Mostra o schema completo (tabelas, colunas, FK) para validação

Fluxo completo de uma pergunta

1. Você:  "Quais professores têm horário livre na quarta à tarde?"

2. LLM chama:  buscar_informacoes("professores horário quarta tarde")
               → API Gemini gera embedding da consulta
               → ChromaDB retorna: disponibilidade_professor, professores

3. LLM chama:  executar_query("SELECT p.nome FROM professores p
                               JOIN disponibilidade_professor d ON d.professor_id = p.id
                               WHERE d.dia_semana = 'quarta' AND d.disponivel = true
                               AND d.hora_inicio <= '14:00' AND d.hora_fim >= '18:00'")

4. Resultado → LLM responde em linguagem natural

Roadmap

⚙️ Etapa 1 - Configuração do Ambiente

Antes de escrever qualquer código, vamos garantir que todas as ferramentas estão instaladas e funcionando corretamente.

1.1 - Clone o projeto

git clone https://github.com/claudiogpt/SmartGIU.git SmartGIL
cd SmartGIL

1.2 - Configure as variáveis de ambiente

cp .env.example .env

Abra o .env e substitua sua_chave_aqui pela sua API Key do Gemini:

# API Key do Google Gemini - obtenha em https://aistudio.google.com
GEMINI_API_KEY=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

# SSL para bancos remotos (false para o banco demo local)
DB_SSL=false

Segurança: Nunca compartilhe sua API Key publicamente. O .gitignore já ignora o arquivo .env - ela não vai para o repositório.

1.3 - Verifique o ambiente

bash scripts/setup-podman.sh

O script verifica Podman, Podman Compose e a presença da GEMINI_API_KEY no .env. Corrija qualquer item marcado com [XX] antes de continuar.

1.4 - Suba os serviços

# Serviços principais + banco de dados demo
podman compose --profile demo up -d

Na primeira execução o Podman baixa as imagens e compila o servidor - aguarde ~2 minutos.

1.5 - Verifique que tudo está rodando

# Health check do servidor MCP
curl http://localhost:3001/health
# Esperado: {"status":"ok","service":"smartgiu-mcp-server"}

# Health check do ChromaDB
curl http://localhost:8000/api/v1/heartbeat
# Esperado: {"nanosecond heartbeat":...}

# Status de todos os containers
podman compose ps

🏗️ Etapa 2 - Explorando a Estrutura do Projeto

Antes de modificar qualquer arquivo, entenda o que já existe e por quê.

SmartGIL/
├── compose.yml               ← orquestra os 3 containers (chromadb, mcp-server, postgres-demo)
├── .env / .env.example       ← variáveis de ambiente (GEMINI_API_KEY, DB_SSL)
├── scripts/
│   └── setup-podman.sh       ← script de verificação do ambiente
│
├── mcp-server/               ← o coração do projeto (TypeScript)
│   ├── Dockerfile            ← como construir a imagem do servidor
│   ├── package.json          ← dependências: express, @mcp/sdk, pg, zod
│   ├── tsconfig.json         ← TypeScript com NodeNext modules
│   └── src/
│       ├── index.ts          ← servidor Express + endpoints SSE/messages/health
│       ├── server.ts         ← instância McpServer + registro das 4 ferramentas
│       ├── db-manager.ts     ← singleton pg.Pool - uma conexão ativa por processo
│       ├── chroma-client.ts  ← cliente REST para ChromaDB + chamadas de embedding ao Gemini
│       └── tools/
│           ├── conectar-banco.ts      ← ferramenta 1: conecta + indexa schema
│           ├── buscar-informacoes.ts  ← ferramenta 2: busca semântica no ChromaDB
│           ├── executar-query.ts      ← ferramenta 3: executa SQL no banco
│           └── listar-schema.ts      ← ferramenta 4: dump completo do schema
│
├── ollama-client/            ← cliente CLI de chat (usa Ollama como LLM - opcional)
│   └── src/index.ts
│
└── demo-db/
    └── init.sql              ← schema acadêmico com 6 tabelas e dados de exemplo

Dependências principais do mcp-server/package.json:

{
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.x", // protocolo MCP
    "express": "^4.x",                   // servidor HTTP
    "pg": "^8.x",                        // driver PostgreSQL
    "zod": "^3.x",                       // validação de schemas
    "cors": "^2.x"                       // CORS para o Claude Desktop
  },
  "devDependencies": {
    "tsx": "^4.x",        // executa TypeScript diretamente
    "typescript": "^5.x"
  }
}

Nota: a API Gemini é acessada via fetch nativo do Node.js - sem nenhum pacote npm adicional.

Nota sobre extensões .js em imports: O projeto usa "module": "NodeNext" no tsconfig.json. Isso exige que imports internos usem extensão .js mesmo sendo arquivos .ts:

import { getPool } from "./db-manager.js"; // correto ✅
import { getPool } from "./db-manager"; // erro ❌

📊 Etapa 3 - O Banco de Dados

3.1 - O schema acadêmico demo

O projeto inclui um banco PostgreSQL de exemplo que simula um sistema de gestão universitária. Veja o arquivo demo-db/init.sql:

-- Departamentos da universidade
CREATE TABLE departamentos (
    id     SERIAL PRIMARY KEY,
    nome   VARCHAR(100) NOT NULL,
    codigo VARCHAR(10)  UNIQUE NOT NULL
);

-- Professores vinculados a departamentos
CREATE TABLE professores (
    id              SERIAL PRIMARY KEY,
    nome            VARCHAR(100) NOT NULL,
    email           VARCHAR(100) UNIQUE NOT NULL,
    departamento_id INTEGER REFERENCES departamentos(id),
    regime          VARCHAR(20) NOT NULL DEFAULT '40h'  -- 20h | 40h | DE
);

-- Disciplinas ofertadas por departamento
CREATE TABLE disciplinas (
    id              SERIAL PRIMARY KEY,
    nome            VARCHAR(100) NOT NULL,
    codigo          VARCHAR(20) UNIQUE NOT NULL,
    carga_horaria   INTEGER NOT NULL,      -- horas/semana
    departamento_id INTEGER REFERENCES departamentos(id)
);

-- Turmas de cada disciplina no semestre
CREATE TABLE turmas (
    id                  SERIAL PRIMARY KEY,
    disciplina_id       INTEGER NOT NULL REFERENCES disciplinas(id),
    professor_id        INTEGER REFERENCES professores(id),  -- pode ser NULL
    semestre            VARCHAR(10) NOT NULL,    -- ex: 2025.1
    codigo_turma        VARCHAR(10) NOT NULL,
    vagas               INTEGER DEFAULT 40,
    alunos_matriculados INTEGER DEFAULT 0
);

-- Horários das turmas (dia + sala + horário)
CREATE TABLE horarios (
    id          SERIAL PRIMARY KEY,
    turma_id    INTEGER NOT NULL REFERENCES turmas(id),
    dia_semana  VARCHAR(15) NOT NULL,  -- segunda | terca | quarta | quinta | sexta
    hora_inicio TIME NOT NULL,
    hora_fim    TIME NOT NULL,
    sala        VARCHAR(20)
);

-- Disponibilidade declarada dos professores
CREATE TABLE disponibilidade_professor (
    id           SERIAL PRIMARY KEY,
    professor_id INTEGER NOT NULL REFERENCES professores(id),
    dia_semana   VARCHAR(15) NOT NULL,
    hora_inicio  TIME NOT NULL,
    hora_fim     TIME NOT NULL,
    disponivel   BOOLEAN NOT NULL DEFAULT TRUE,
    observacao   VARCHAR(200)
);

O banco já vem populado com:

  • 3 departamentos (Computação, Ciências Humanas, Engenharia)
  • 10 professores em diferentes regimes
  • 8 disciplinas com carga horária
  • 15 turmas no semestre 2025.1
  • 30+ registros de horários e disponibilidade

3.2 - Lendo o schema via information_schema

O PostgreSQL expõe a estrutura de qualquer banco através de um schema especial chamado information_schema. É assim que o conectar_banco descobre todas as tabelas automaticamente:

-- Lista todas as tabelas do usuário no schema public
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name;

-- Lista todas as colunas com seus tipos
SELECT table_name, column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position;

-- Lista todas as relações de chave estrangeira (FK)
SELECT
  kcu.table_name,
  kcu.column_name,
  ccu.table_name  AS foreign_table_name,
  ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
  ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
  ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
  AND tc.table_schema = 'public';

Essas três queries juntas dão ao sistema tudo que ele precisa para descrever qualquer banco PostgreSQL.


🔍 Etapa 4 - ChromaDB e Embeddings Vetoriais

4.1 - O cliente ChromaDB (chroma-client.ts)

O arquivo mcp-server/src/chroma-client.ts implementa toda a comunicação com o ChromaDB e com a API Gemini - usando apenas fetch puro, sem pacotes adicionais:

// Configuração via variáveis de ambiente
const CHROMA_URL = process.env.CHROMADB_URL ?? "http://chromadb:8000";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? "";
const EMBED_MODEL = "gemini-embedding-2";
const COLLECTION_NAME = "db_schema";

const GEMINI_EMBED_URL = `https://generativelanguage.googleapis.com/v1/models/${EMBED_MODEL}:embedContent`;

4.2 - Gerando embeddings com a API Gemini

Para transformar textos em vetores numéricos, fazemos uma chamada HTTP à API Gemini. O endpoint embedContent processa um texto por vez — para indexar várias tabelas de uma vez, chamamos em paralelo com Promise.all:

async function getEmbedding(text: string): Promise<number[]> {
  const res = await fetch(GEMINI_EMBED_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-goog-api-key": GEMINI_API_KEY,
    },
    body: JSON.stringify({
      model: "models/gemini-embedding-2",
      content: { parts: [{ text }] },
    }),
  });

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Gemini embedding error (${res.status}): ${body}`);
  }

  const data = await res.json();
  return data.embedding.values;
  // cada vetor tem 3072 dimensões
}

async function getEmbeddingsBatch(texts: string[]): Promise<number[][]> {
  // Chama embedContent em paralelo para cada texto
  return Promise.all(texts.map((text) => getEmbedding(text)));
}

Formato da requisição embedContent:

POST https://generativelanguage.googleapis.com/v1/models/gemini-embedding-2:embedContent
x-goog-api-key: SUA_CHAVE
Content-Type: application/json

{
  "model": "models/gemini-embedding-2",
  "content": { "parts": [{ "text": "Tabela: professores..." }] }
}

Resposta:

{
  "embedding": {
    "values": [0.021, -0.043, 0.187, ...]
  }
}

4.3 - Testando a API com um cliente HTTP

Antes de integrar ao código, é muito útil testar a API Gemini diretamente com um cliente HTTP. Isso confirma que sua chave funciona e ajuda a entender o formato de resposta.

Opção A - curl (terminal):

# Substitua SUA_CHAVE_AQUI pela sua GEMINI_API_KEY (formato AQ....)
curl -s \
  "https://generativelanguage.googleapis.com/v1/models/gemini-embedding-2:embedContent" \
  -H "Content-Type: application/json" \
  -H "x-goog-api-key: SUA_CHAVE_AQUI" \
  -d '{
    "model": "models/gemini-embedding-2",
    "content": {
      "parts": [{"text": "professores disponíveis na quarta-feira"}]
    }
  }'

O resultado deve ser um JSON com um campo embedding.values contendo 3072 números.

Opção B - Reqly (cliente HTTP com interface visual):

Reqly é um cliente HTTP moderno e gratuito. Configure assim:

  1. Método: POST
  2. URL: https://generativelanguage.googleapis.com/v1/models/gemini-embedding-2:embedContent
  3. Headers:
    • Content-Type: application/json
    • x-goog-api-key: SUA_CHAVE_AQUI
  4. Body (JSON):
{
  "model": "models/gemini-embedding-2",
  "content": {
    "parts": [{ "text": "professores disponíveis na quarta-feira" }]
  }
}
  1. Clique em Send e observe a resposta com embedding.values.

Dica: Se receber erro 404, verifique o nome do modelo (gemini-embedding-2) e que a chave está no header x-goog-api-key, não na URL.

4.4 - Indexando o schema no ChromaDB

Quando conectar_banco é chamada, cada tabela vira um documento textual que é convertido em embedding e armazenado:

export async function indexDocuments(
  docs: Array<{ id: string; text: string; metadata: Record<string, string> }>,
): Promise<void> {
  const collection = await getOrCreateCollection();

  // Envia todos os documentos de uma vez para o Gemini (até 100 por batch)
  const embeddings = await getEmbeddingsBatch(docs.map((d) => d.text));

  // Armazena documentos + embeddings no ChromaDB
  await fetch(`${CHROMA_URL}/api/v1/collections/${collection.id}/upsert`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      ids: docs.map((d) => d.id),
      embeddings,
      documents: docs.map((d) => d.text),
      metadatas: docs.map((d) => d.metadata),
    }),
  });
}

O texto que representa cada tabela é gerado assim no conectar-banco.ts:

Tabela: disponibilidade_professor
Colunas:
  id: integer (obrigatório)
  professor_id: integer (obrigatório)
  dia_semana: character varying (obrigatório)
  hora_inicio: time without time zone (obrigatório)
  hora_fim: time without time zone (obrigatório)
  disponivel: boolean (obrigatório)
  observacao: character varying
Relações (FK):
  professor_id → professores.id

4.5 - Buscando tabelas relevantes

Quando buscar_informacoes é chamada com uma pergunta, o mesmo processo acontece com o texto da pergunta:

export async function search(
  queryText: string,
  nResults = 5,
): Promise<Array<{ text: string; metadata: Record<string, string> }>> {
  const collection = await getOrCreateCollection();

  // 1. Converte a pergunta em embedding via API Gemini
  const [queryEmbedding] = await getEmbeddingsBatch([queryText]);

  // 2. Busca os documentos mais próximos no espaço vetorial
  const res = await fetch(
    `${CHROMA_URL}/api/v1/collections/${collection.id}/query`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        query_embeddings: [queryEmbedding],
        n_results: Math.min(nResults, 10),
        include: ["documents", "metadatas", "distances"],
      }),
    },
  );

  const data = await res.json();

  // 3. Retorna documentos ordenados por relevância semântica
  return data.documents[0]
    .map((text: string | null, i: number) => ({
      text: text ?? "",
      metadata: data.metadatas[0][i] ?? {},
    }))
    .filter((r: { text: string }) => r.text !== "");
}

⚙️ Etapa 5 - O Servidor MCP

5.1 - Gerenciando a conexão PostgreSQL (db-manager.ts)

O db-manager.ts mantém um singleton com a conexão ativa:

import pg from "pg";
const { Pool } = pg;

let pool: pg.Pool | null = null;
let connectionInfo: ConnectionConfig | null = null;

export async function connectDb(config: ConnectionConfig): Promise<void> {
  // Fecha conexão anterior se existir
  if (pool) {
    await pool.end().catch(() => {});
    pool = null;
  }

  const sslConfig =
    process.env.DB_SSL === "false" ? false : { rejectUnauthorized: false };

  pool = new Pool({
    host: config.host,
    port: config.port,
    database: config.database,
    user: config.user,
    password: config.password,
    ssl: sslConfig,
    connectionTimeoutMillis: 10_000,
    idleTimeoutMillis: 30_000,
    max: 5, // máximo de 5 conexões simultâneas no pool
  });

  // Testa a conexão imediatamente
  const client = await pool.connect();
  try {
    await client.query("SELECT 1");
  } finally {
    client.release();
  }

  connectionInfo = config;
}

export function isConnected(): boolean {
  return pool !== null;
}

export function getPool(): pg.Pool {
  if (!pool)
    throw new Error("Banco não conectado. Use conectar_banco primeiro.");
  return pool;
}

Decisão de design: As credenciais ficam apenas em memória durante a execução do container. Cada reinício exige nova chamada ao conectar_banco. Isso é intencional - evita persistir senhas em disco.

5.2 - O servidor Express com SSE (index.ts)

O arquivo index.ts configura o servidor HTTP que expõe o protocolo MCP:

import express from "express";
import cors from "cors";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { createMcpServer } from "./server.js";

const app = express();
app.use(cors());
app.use(express.json());

const { server, transports } = createMcpServer();

// Health check - confirma que o servidor está ativo
app.get("/health", (_req, res) => {
  res.json({ status: "ok", service: "smartgiu-mcp-server" });
});

// Endpoint SSE - cada cliente MCP conecta aqui e mantém a stream aberta
app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  transports.set(transport.sessionId, transport);

  res.on("close", () => {
    transports.delete(transport.sessionId); // limpa quando cliente desconecta
  });

  await server.connect(transport);
});

// Endpoint de mensagens - o cliente envia tool calls aqui
app.post("/messages", async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transports.get(sessionId);

  if (!transport) {
    return res
      .status(404)
      .json({ error: `Sessão não encontrada: ${sessionId}` });
  }

  await transport.handlePostMessage(req, res, req.body);
});

app.listen(3001, () => {
  console.log("SmartGIL MCP Server rodando na porta 3001");
});

Por que SSE (Server-Sent Events)? O Claude Desktop usa SSE como transporte MCP - o cliente abre uma conexão HTTP persistente e o servidor envia eventos de volta. É simples, funciona em qualquer HTTP e não requer WebSockets.

5.3 - Registrando as ferramentas (server.ts)

O server.ts cria a instância MCP e registra as quatro ferramentas com seus schemas Zod:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
// importa os handlers de cada ferramenta...

export function createMcpServer() {
  const server = new McpServer({ name: "smartgil", version: "1.0.0" });
  const transports = new Map<string, SSEServerTransport>();

  server.tool(
    "conectar_banco",
    "Conecta ao PostgreSQL e indexa o schema no ChromaDB para buscas semânticas.",
    {
      host: z.string().describe("Hostname ou IP do banco"),
      port: z.number().int().default(5432).describe("Porta TCP (padrão: 5432)"),
      database: z.string().describe("Nome do banco de dados"),
      usuario: z.string().describe("Usuário PostgreSQL"),
      senha: z.string().describe("Senha do usuário"),
    },
    conectarBanco,
  );

  server.tool(
    "buscar_informacoes",
    "Busca semanticamente quais tabelas são relevantes para uma pergunta.",
    {
      consulta: z.string().describe("Pergunta em linguagem natural"),
      n_resultados: z.number().int().min(1).max(10).default(5),
    },
    buscarInformacoes,
  );

  server.tool("executar_query", "...", { sql: z.string() }, executarQuery);
  server.tool("listar_schema", "...", {}, listarSchema);

  return { server, transports };
}

O segundo argumento do server.tool() é a descrição - é o que o LLM lê para decidir qual ferramenta chamar. Uma boa descrição é fundamental.


🔧 Etapa 6 - As Ferramentas MCP

Cada ferramenta é um arquivo independente em src/tools/. Todas seguem o mesmo contrato de retorno: { content: [{ type: 'text', text: string }] }.

Ferramenta 1 - conectar_banco

Conecta ao banco, lê o schema via information_schema e indexa no ChromaDB:

export async function conectarBanco(input: {
  host: string; port: number; database: string; usuario: string; senha: string;
}) {
  try {
    // 1. Conecta ao PostgreSQL
    await connectDb({ host: input.host, port: input.port,
                      database: input.database, user: input.usuario,
                      password: input.senha });

    // 2. Lê tabelas, colunas e FKs em paralelo
    const [tablesRes, columnsRes, fkRes] = await Promise.all([
      query(`SELECT table_name FROM information_schema.tables
             WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`),
      query(`SELECT table_name, column_name, data_type, is_nullable
             FROM information_schema.columns WHERE table_schema = 'public'
             ORDER BY table_name, ordinal_position`),
      query(`SELECT kcu.table_name, kcu.column_name,
                    ccu.table_name AS foreign_table_name, ccu.column_name AS foreign_column_name
             FROM information_schema.table_constraints tc
             JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
             JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name
             WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'`),
    ]);

    // 3. Monta documentos e indexa no ChromaDB (embeddings via API Gemini)
    await resetCollection();
    const docs = tablesRes.rows.map(t => ({
      id:   `table_${t.table_name}`,
      text: formatTableDescription(t.table_name, columnsRes.rows, fkRes.rows),
      metadata: { table_name: t.table_name, ... },
    }));
    await indexDocuments(docs);

    // 4. Retorna resumo para o LLM
    return { content: [{ type: 'text' as const, text: `✅ Conectado. ${docs.length} tabelas indexadas.` }] };

  } catch (err) {
    return { content: [{ type: 'text' as const, text: `❌ Erro: ${err}` }], isError: true };
  }
}

Ferramenta 2 - buscar_informacoes

Faz busca vetorial no ChromaDB com o texto da pergunta:

export async function buscarInformacoes(input: {
  consulta: string;
  n_resultados: number;
}) {
  if (!isConnected()) {
    return {
      content: [
        { type: "text" as const, text: "⚠️ Use conectar_banco primeiro." },
      ],
      isError: true,
    };
  }

  const results = await search(input.consulta, input.n_resultados);

  const formatted = results
    .map(
      (r, i) =>
        `### Resultado ${i + 1} - ${r.metadata.table_name}\n\n${r.text}`,
    )
    .join("\n\n---\n\n");

  return {
    content: [
      { type: "text" as const, text: `🔍 Tabelas relevantes:\n\n${formatted}` },
    ],
  };
}

Ferramenta 3 - executar_query

Executa o SQL gerado pelo LLM e formata o resultado como tabela Markdown:

export async function executarQuery(input: { sql: string }) {
  if (!isConnected()) {
    return {
      content: [
        { type: "text" as const, text: "⚠️ Use conectar_banco primeiro." },
      ],
      isError: true,
    };
  }

  try {
    const result = await query(input.sql.trim());

    if (result.rows.length === 0) {
      return {
        content: [
          {
            type: "text" as const,
            text: "✅ Query executada - nenhuma linha retornada.",
          },
        ],
      };
    }

    // Formata resultado como tabela Markdown
    const columns = Object.keys(result.rows[0]);
    const header = `| ${columns.join(" | ")} |`;
    const separator = `| ${columns.map(() => "---").join(" | ")} |`;
    const rows = result.rows
      .map(
        (row) =>
          `| ${columns.map((col) => String(row[col] ?? "")).join(" | ")} |`,
      )
      .join("\n");

    return {
      content: [
        {
          type: "text" as const,
          text: `✅ ${result.rowCount} linha(s)\n\n${header}\n${separator}\n${rows}`,
        },
      ],
    };
  } catch (err) {
    return {
      content: [{ type: "text" as const, text: `❌ Erro SQL: ${err}` }],
      isError: true,
    };
  }
}

Ferramenta 4 - listar_schema

Retorna o schema completo formatado com todos os tipos e FK:

export async function listarSchema(_input: Record<string, unknown>) {
  if (!isConnected()) {
    return {
      content: [
        { type: "text" as const, text: "⚠️ Use conectar_banco primeiro." },
      ],
      isError: true,
    };
  }

  // Lê tabelas + colunas + FKs do information_schema
  // Agrupa por tabela e formata como:
  // ## professores
  //   id  integer  NOT NULL
  //   nome  character varying  NOT NULL
  //   ↳ departamento_id → departamentos(id)

  const schema = /* ... agrupamento e formatação ... */ "";
  return {
    content: [{ type: "text" as const, text: `# Schema\n\n${schema}` }],
  };
}

🐳 Etapa 7 - Containers com Podman

7.1 - O Dockerfile

O mcp-server/Dockerfile define como construir a imagem do servidor:

FROM node:20-alpine

# curl é necessário para o healthcheck interno
RUN apk add --no-cache curl

WORKDIR /app

# Copia lockfile primeiro - aproveita cache de camadas do Podman
COPY package*.json ./
RUN npm ci           # instala exatamente o que está no package-lock.json

COPY tsconfig.json ./
COPY src ./src

EXPOSE 3001

# Verifica se o servidor está respondendo a cada 15s
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:3001/health || exit 1

# Executa TypeScript diretamente (sem build prévio)
CMD ["node_modules/.bin/tsx", "src/index.ts"]

Por que npm ci e não npm install?

  • npm ci instala exatamente o que está no package-lock.json - build reprodutível
  • Mais rápido (não resolve conflitos de versão)
  • Falha se o lockfile não existir ou estiver desatualizado (sinal de problema)

Regra importante: Sempre execute npm install localmente para gerar o package-lock.json antes de modificar o Dockerfile. O npm ci requer o lockfile.

7.2 - O compose.yml

O compose.yml orquestra os três serviços e define como eles se comunicam:

services:
  # Banco vetorial para o schema indexado
  chromadb:
    image: chromadb/chroma:0.6.3
    ports:
      - "8000:8000" # 8000 do host → 8000 do container
    volumes:
      - chroma_data:/chroma/chroma # persiste dados entre reinícios
    environment:
      - ANONYMIZED_TELEMETRY=False
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/heartbeat"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s # aguarda 30s antes de começar a verificar

  # Servidor MCP - só sobe quando ChromaDB estiver healthy
  mcp-server:
    build: ./mcp-server # constrói a partir do Dockerfile
    ports:
      - "3001:3001"
    environment:
      - PORT=3001
      - CHROMADB_URL=http://chromadb:8000 # DNS interno do Podman
      - GEMINI_API_KEY=${GEMINI_API_KEY}   # passa a chave do .env para o container
      - DB_SSL=${DB_SSL:-false}
    depends_on:
      chromadb:
        condition: service_healthy # aguarda ChromaDB estar healthy
    restart: unless-stopped

  # Banco demo (opcional - ativado com --profile demo)
  postgres-demo:
    image: postgres:16-alpine
    profiles:
      - demo
    ports:
      - "5433:5432" # 5433 no host, 5432 interno (porta padrão PG)
    environment:
      - POSTGRES_DB=demo_giu
      - POSTGRES_USER=demo
      - POSTGRES_PASSWORD=demo123
    volumes:
      - ./demo-db/init.sql:/docker-entrypoint-initdb.d/init.sql
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U demo -d demo_giu"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

volumes:
  chroma_data: # dados do ChromaDB
  postgres_data: # dados do PostgreSQL demo

Conceitos-chave do Compose:

Conceito O que faz
ports: "5433:5432" Mapeia porta 5433 do host para 5432 do container
volumes: Persiste dados entre reinícios (sem isso tudo se perde)
depends_on: condition: service_healthy Garante ordem de inicialização
profiles: [demo] Serviço opcional - só sobe com --profile demo
restart: unless-stopped Reinicia automaticamente em caso de falha

7.3 - Comandos essenciais

# Sobe todos os serviços (em background)
podman compose up -d

# Sobe com banco demo
podman compose --profile demo up -d

# Vê status e saúde de cada container
podman compose ps

# Logs em tempo real de um serviço
podman compose logs -f mcp-server
podman compose logs -f chromadb

# Reconstrói a imagem após mudanças no código
podman compose build
podman compose up -d

# Para os containers (mantém os volumes/dados)
podman compose down

# Para e apaga todos os volumes (dados perdidos)
podman compose down -v

🤖 Etapa 8 - Integração e Testes

Com tudo rodando, hora de usar o sistema completo.

8.1 - Verificação de saúde

# Confirme que os serviços estão prontos
curl http://localhost:3001/health
# → {"status":"ok","service":"smartgiu-mcp-server"}

curl http://localhost:8000/api/v1/heartbeat
# → {"nanosecond heartbeat":1234567890}

podman compose ps
# Todos os serviços devem estar healthy

8.2 - Configurando o Claude Desktop

Adicione ao arquivo de configuração do Claude Desktop:

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json Windows: %APPDATA%\Claude\claude_desktop_config.json Linux: ~/.config/Claude/claude_desktop_config.json

{
  "mcpServers": {
    "smartgil": {
      "type": "sse",
      "url": "http://localhost:3001/sse"
    }
  }
}

Reinicie o Claude Desktop completamente. O ícone de ferramentas (🔧) deve aparecer na interface - clique nele para ver as 4 ferramentas listadas.

8.3 - Primeira sessão completa

Passo 1 - Conecte ao banco demo:

Conecte no banco de dados: host postgres-demo, porta 5432,
database demo_giu, usuário demo, senha demo123

Por que postgres-demo e não localhost? O mcp-server roda dentro de um container. Para ele, localhost é o próprio container. O banco demo é outro container, acessível pelo nome do serviço postgres-demo.

Passo 2 - Explore o schema:

  • "Quais tabelas existem e o que cada uma armazena?"
  • "Mostre o schema completo"

Passo 3 - Faça perguntas sobre os dados:

  • "Quais professores estão com horário livre na quarta-feira à tarde?"
  • "Qual turma está mais próxima da capacidade máxima?"
  • "Quantas turmas cada professor está ministrando em 2025.1?"
  • "Existe alguma turma sem professor alocado?"

🦙 Alternativa: Ollama (LLMs locais)

O Ollama é uma ferramenta incrível que permite rodar modelos de linguagem completamente offline - no seu próprio computador, sem enviar dados para a nuvem, sem custo por token e sem depender de internet.

Por que não usamos no workshop? No ambiente do evento, não foi possível instalar o Ollama nas máquinas disponíveis. Para workshops presenciais e ambientes corporativos com restrições de rede, a API Gemini (apenas uma chave de texto) é mais simples de configurar.

Para uso em produção ou projetos pessoais, o Ollama é uma excelente escolha, especialmente se privacidade de dados for um requisito.

Vantagens do Ollama

  • Privacidade total - nenhum dado sai do seu computador
  • Funciona offline - sem dependência de internet
  • Sem limite de requisições - rode quantas queries quiser
  • Múltiplos modelos - embeddings, chat, code, etc.

Como substituir a API Gemini pelo Ollama

1. Instale o Ollama e os modelos:

# Instale em: https://ollama.com

# Modelo de embeddings (~274 MB) - equivalente ao gemini-embedding-2
ollama pull nomic-embed-text

# Modelo de chat para o ollama-client (~8.9 GB)
ollama pull gemma4

# Verifique os modelos instalados
ollama list

# Confirme que o servidor está rodando
curl http://localhost:11434/api/tags

2. Altere o mcp-server/src/chroma-client.ts:

Substitua a função getEmbeddingsBatch por uma chamada ao Ollama:

// Versão Ollama (substitui a versão Gemini)
const OLLAMA_HOST =
  process.env.OLLAMA_HOST ?? "http://host.containers.internal:11434";
const EMBED_MODEL = process.env.EMBED_MODEL ?? "nomic-embed-text";

async function getEmbedding(text: string): Promise<number[]> {
  const res = await fetch(`${OLLAMA_HOST}/api/embeddings`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model: EMBED_MODEL, prompt: text }),
  });

  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Ollama embedding error (${res.status}): ${body}`);
  }

  const data = (await res.json()) as { embedding: number[] };
  return data.embedding; // 768 dimensões com nomic-embed-text (diferente das 3072 do gemini-embedding-2)
}

3. Atualize o .env:

# Substitua GEMINI_API_KEY por estas variáveis
OLLAMA_HOST=http://host.containers.internal:11434
EMBED_MODEL=nomic-embed-text
DB_SSL=false

4. Atualize o compose.yml:

environment:
  - PORT=3001
  - CHROMADB_URL=http://chromadb:8000
  - OLLAMA_HOST=${OLLAMA_HOST:-http://host.containers.internal:11434}
  - EMBED_MODEL=${EMBED_MODEL:-nomic-embed-text}
  - DB_SSL=${DB_SSL:-false}

Por que host.containers.internal? O mcp-server roda dentro de um container Podman. Para acessar o Ollama que roda no seu computador (host), usamos esse hostname especial que o Podman resolve automaticamente - sem configuração extra.

5. Usando o ollama-client (chat via terminal):

O projeto inclui um cliente de chat completo que usa o Ollama como LLM:

cd ollama-client
npm install
npm start

Saída esperada:

🧠 SmartGIU - Converse com seu banco de dados
   MCP Server: http://localhost:3001/sse
   Ollama: http://localhost:11434  modelo: gemma4

✅ MCP conectado - ferramentas: conectar_banco, buscar_informacoes, executar_query, listar_schema

💬 Digite sua pergunta (ou "sair" para encerrar):

Você: conecte no banco postgres-demo porta 5432 banco demo_giu usuário demo senha demo123

SmartGIL: 🔧 conectar_banco... ✓
          ✅ Conectado a "demo_giu" em postgres-demo:5432 - 6 tabelas indexadas.

Você: quais professores têm horário livre na quarta-feira à tarde?

SmartGIL: 🔧 buscar_informacoes... ✓
          🔧 executar_query... ✓

          Professores disponíveis na quarta-feira à tarde:
          • Ana Lima (Computação)
          • Felipe Nunes (Engenharia) - 14h às 18h

Para usar um modelo diferente:

OLLAMA_MODEL=gemma3 npm start         # gemma3 usa modo XML automaticamente
OLLAMA_USE_PROMPTING=true npm start   # força modo XML mesmo com modelos que suportam tools

Boas Práticas

Segurança

  • API Key no .env: A GEMINI_API_KEY fica no .env, que é ignorado pelo .gitignore. Nunca comite a chave no repositório
  • Credenciais do banco em memória: As senhas nunca são persistidas em disco - ficam apenas no processo do mcp-server durante a execução. Cada reinício exige nova autenticação via conectar_banco
  • SSL: Use DB_SSL=true para conexões a bancos remotos em produção. O SmartGIL aceita certificados auto-assinados (rejectUnauthorized: false) para facilitar a configuração
  • Permissões mínimas: O usuário PostgreSQL deveria ter apenas SELECT em produção - nunca DROP, TRUNCATE ou INSERT a não ser que a ferramenta precise

Containers e Build

  • Lockfile obrigatório: Sempre execute npm install localmente para atualizar o package-lock.json antes de modificar o Dockerfile. O npm ci falha sem lockfile atualizado
  • Rebuild após mudanças: Após modificar o código, execute podman compose build e depois podman compose up -d
  • Healthchecks: O endpoint correto do ChromaDB é /api/v1/heartbeat. Nunca use /api/v2 - não existe na imagem estável e causa unhealthy indefinidamente

Desenvolvimento

  • Extensões .js: Com "module": "NodeNext" no TypeScript, todos os imports internos devem usar .js mesmo sendo arquivos .ts
  • Hot reload local: Para desenvolver sem containers, use npm run dev no mcp-server - o tsx recompila automaticamente a cada mudança
  • Logs: Use podman compose logs -f mcp-server para debugar em tempo real

Diagnóstico rápido

# Status completo do ambiente
podman compose ps

# Saúde dos serviços
curl http://localhost:3001/health
curl http://localhost:8000/api/v1/heartbeat

# Reiniciar tudo do zero (apaga dados)
podman compose down -v
podman compose --profile demo up -d

🧑🏻‍💻 Autores


Artur Bomtempo

Cláudio Augusto

Licença

Este projeto está licenciado sob a MIT License.

About

Hands-on workshop focused on building an MCP (Model Context Protocol) server integrated with a local database, exploring database fundamentals, AI data integration, and modern software engineering concepts.

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages