Are you the author? Sign in to claim
Hands-on workshop focused on building an MCP (Model Context Protocol) server integrated with a local database, exploring
Workshop prático: construindo um servidor MCP integrado a um banco de dados PostgreSQL
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.
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 |
Este projeto pode ser aplicado em diversas situações do mundo real:
Siga com precisão as orientações de configuração do ambiente para garantir que o projeto funcione corretamente durante o workshop.
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 podman → podman machine init → podman machine start |
| Windows | Podman Desktop |
Verifique a instalação:
podman --version # deve ser 6.0+
podman compose version
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:
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.
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+
Para uma experiência completa de chat com interface gráfica. Baixe em claude.ai/download.
Imagine que você é analista em uma universidade e precisa responder perguntas sobre dados acadêmicos diariamente. Para cada pergunta, você precisaria:
turmas, professores, horariosCom MCP + LLM, você simplesmente pergunta em português. O sistema faz todo o resto.
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.
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';
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"
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.
┌────────────────────────────────────────────────────────────────┐
│ 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) │ └──────────────────────┘
└──────────────────────┘
| 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 |
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
Antes de escrever qualquer código, vamos garantir que todas as ferramentas estão instaladas e funcionando corretamente.
git clone https://github.com/claudiogpt/SmartGIU.git SmartGIL
cd SmartGIL
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
.gitignorejá ignora o arquivo.env- ela não vai para o repositório.
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.
# 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.
# 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
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
fetchnativo do Node.js - sem nenhum pacote npm adicional.
Nota sobre extensões
.jsem imports: O projeto usa"module": "NodeNext"notsconfig.json. Isso exige que imports internos usem extensão.jsmesmo sendo arquivos.ts:hljs language-typescriptimport { getPool } from "./db-manager.js"; // correto ✅ import { getPool } from "./db-manager"; // erro ❌
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:
information_schemaO 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.
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`;
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, ...]
}
}
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:
POSThttps://generativelanguage.googleapis.com/v1/models/gemini-embedding-2:embedContentContent-Type: application/jsonx-goog-api-key: SUA_CHAVE_AQUI{
"model": "models/gemini-embedding-2",
"content": {
"parts": [{ "text": "professores disponíveis na quarta-feira" }]
}
}
embedding.values.Dica: Se receber erro 404, verifique o nome do modelo (
gemini-embedding-2) e que a chave está no headerx-goog-api-key, não na URL.
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
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 !== "");
}
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.
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.
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.
Cada ferramenta é um arquivo independente em src/tools/. Todas seguem o mesmo contrato de retorno: { content: [{ type: 'text', text: string }] }.
conectar_bancoConecta 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 };
}
}
buscar_informacoesFaz 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}` },
],
};
}
executar_queryExecuta 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,
};
}
}
listar_schemaRetorna 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}` }],
};
}
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ívelRegra importante: Sempre execute
npm installlocalmente para gerar opackage-lock.jsonantes de modificar o Dockerfile. Onpm cirequer o lockfile.
compose.ymlO 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 |
# 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
Com tudo rodando, hora de usar o sistema completo.
# 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
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.
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-demoe nãolocalhost? 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çopostgres-demo.
Passo 2 - Explore o schema:
Passo 3 - Faça perguntas sobre os dados:
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.
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
GEMINI_API_KEY fica no .env, que é ignorado pelo .gitignore. Nunca comite a chave no repositóriomcp-server durante a execução. Cada reinício exige nova autenticação via conectar_bancoDB_SSL=true para conexões a bancos remotos em produção. O SmartGIL aceita certificados auto-assinados (rejectUnauthorized: false) para facilitar a configuraçãoSELECT em produção - nunca DROP, TRUNCATE ou INSERT a não ser que a ferramenta precisenpm install localmente para atualizar o package-lock.json antes de modificar o Dockerfile. O npm ci falha sem lockfile atualizadopodman compose build e depois podman compose up -d/api/v1/heartbeat. Nunca use /api/v2 - não existe na imagem estável e causa unhealthy indefinidamente.js: Com "module": "NodeNext" no TypeScript, todos os imports internos devem usar .js mesmo sendo arquivos .tsnpm run dev no mcp-server - o tsx recompila automaticamente a cada mudançapodman compose logs -f mcp-server para debugar em tempo real# 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
Este projeto está licenciado sob a MIT License.
Run Claude Code as an MCP server so any agent can delegate coding tasks to it
Browser automation using accessibility snapshots instead of screenshots
Google's universal MCP server supporting PostgreSQL, MySQL, MongoDB, Redis, and 10+ databases
Official GitHub integration for repos, issues, PRs, and CI/CD workflows