feat: Add database explorer page with SQLite management

- Add /database page with table explorer, query executor and stats
- Implement MCP tools: list_tables, get_table_schema, get_table_data,
  get_database_stats, execute_query
- Add database API endpoints with security (SELECT only)
- Add database icon to toolbar
This commit is contained in:
2026-02-13 06:43:52 -06:00
parent 8a017db777
commit 97ef49aea4
7 changed files with 1357 additions and 2 deletions

View File

@@ -24,8 +24,13 @@ import {
unregisterProjectCanvasTools,
PROJECT_CANVAS_TOOLS
} from './tools/projectCanvasTools'
import {
registerDatabaseTools,
unregisterDatabaseTools,
DATABASE_TOOLS
} from './tools/databaseTools'
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas'
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database'
interface PageToolSet {
register: () => void
@@ -85,6 +90,11 @@ const pageTools: Record<PageName, PageToolSet> = {
register: registerThemeTools,
unregister: unregisterThemeTools,
toolNames: THEME_TOOLS
},
database: {
register: registerDatabaseTools,
unregister: unregisterDatabaseTools,
toolNames: DATABASE_TOOLS
}
}

View File

@@ -0,0 +1,231 @@
import { registerTool, unregisterTools } from '../webmcp'
export const DATABASE_TOOLS = [
'list_tables',
'get_table_schema',
'get_table_data',
'get_database_stats',
'execute_query'
]
const API_BASE = 'http://localhost:4101'
export function registerDatabaseTools() {
// list_tables
registerTool(
'list_tables',
'Lista todas las tablas de la base de datos SQLite con su conteo de registros',
{
type: 'object',
properties: {}
},
async () => {
try {
const res = await fetch(`${API_BASE}/api/database/tables`)
if (!res.ok) throw new Error('Failed to fetch tables')
const tables = await res.json()
if (tables.length === 0) {
return 'No hay tablas en la base de datos'
}
const tableList = tables.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
const total = tables.reduce((sum: number, t: any) => sum + t.count, 0)
return `Tablas en la base de datos (${tables.length}):\n\n${tableList}\n\nTotal de registros: ${total}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_table_schema
registerTool(
'get_table_schema',
'Obtiene el esquema (columnas y tipos) de una tabla',
{
type: 'object',
properties: {
table: {
type: 'string',
description: 'Nombre de la tabla'
}
},
required: ['table']
},
async (args: { table: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/tables/${args.table}/schema`)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch schema')
}
const schema = await res.json()
if (schema.length === 0) {
return `La tabla "${args.table}" no tiene columnas definidas`
}
const columns = schema.map((col: any) => {
const flags = []
if (col.pk) flags.push('PRIMARY KEY')
if (col.notnull) flags.push('NOT NULL')
const flagStr = flags.length > 0 ? ` (${flags.join(', ')})` : ''
return ` - ${col.name}: ${col.type}${flagStr}`
}).join('\n')
return `Esquema de la tabla "${args.table}":\n\n${columns}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_table_data
registerTool(
'get_table_data',
'Obtiene los datos de una tabla con paginacion',
{
type: 'object',
properties: {
table: {
type: 'string',
description: 'Nombre de la tabla'
},
limit: {
type: 'number',
description: 'Numero de registros a retornar (default: 20, max: 100)'
},
offset: {
type: 'number',
description: 'Registros a saltar para paginacion (default: 0)'
}
},
required: ['table']
},
async (args: { table: string; limit?: number; offset?: number }) => {
try {
const limit = Math.min(args.limit || 20, 100)
const offset = args.offset || 0
const res = await fetch(
`${API_BASE}/api/database/tables/${args.table}/data?limit=${limit}&offset=${offset}`
)
if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch data')
}
const result = await res.json()
if (result.rows.length === 0) {
return `La tabla "${args.table}" no tiene registros`
}
// Format as readable table
const rows = result.rows.map((row: any, idx: number) => {
const entries = Object.entries(row).map(([k, v]) => {
let value = v
if (typeof v === 'string' && v.length > 50) {
value = v.substring(0, 50) + '...'
} else if (typeof v === 'object') {
value = JSON.stringify(v).substring(0, 50) + '...'
}
return `${k}: ${value}`
}).join(', ')
return `[${offset + idx + 1}] ${entries}`
}).join('\n')
return `Datos de "${args.table}" (${offset + 1}-${offset + result.rows.length} de ${result.total}):\n\n${rows}\n\nUsa offset=${offset + limit} para ver mas registros`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// get_database_stats
registerTool(
'get_database_stats',
'Obtiene estadisticas generales de la base de datos',
{
type: 'object',
properties: {}
},
async () => {
try {
const res = await fetch(`${API_BASE}/api/database/stats`)
if (!res.ok) throw new Error('Failed to fetch stats')
const stats = await res.json()
return `Estadisticas de la base de datos:\n\n` +
` Tamano: ${stats.size}\n` +
` Tablas: ${stats.tables}\n` +
` Registros totales: ${stats.totalRecords}\n\n` +
`Desglose por tabla:\n` +
stats.breakdown.map((t: any) => ` - ${t.name}: ${t.count} registros`).join('\n')
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
// execute_query
registerTool(
'execute_query',
'Ejecuta una consulta SQL SELECT en la base de datos (solo lectura)',
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Consulta SQL (solo SELECT permitido)'
}
},
required: ['query']
},
async (args: { query: string }) => {
try {
const res = await fetch(`${API_BASE}/api/database/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: args.query })
})
const result = await res.json()
if (!res.ok) {
return `Error en la consulta: ${result.error}`
}
if (result.rows.length === 0) {
return 'La consulta no retorno resultados'
}
// Format results
const columns = Object.keys(result.rows[0])
const header = columns.join(' | ')
const separator = columns.map(() => '---').join(' | ')
const rows = result.rows.slice(0, 50).map((row: any) => {
return columns.map(col => {
let value = row[col]
if (value === null) return 'NULL'
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string' && value.length > 40) {
return value.substring(0, 40) + '...'
}
return String(value)
}).join(' | ')
}).join('\n')
const truncated = result.rows.length > 50 ? `\n\n... y ${result.rows.length - 50} filas mas` : ''
return `Resultados (${result.rows.length} filas):\n\n${header}\n${separator}\n${rows}${truncated}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}
export function unregisterDatabaseTools() {
unregisterTools(DATABASE_TOOLS)
}