MCP/Rules Improvements + MCP Prompts (#357)

- Use ESM for building the MCP Server
- Added own Postgres dependency to MCP Server for querying tables and other entities in MCP
- Vastly improved AI Agent rules
- Added MCP Prompts for reviewing code and planning features
- Minor refactoring
This commit is contained in:
Giancarlo Buomprisco
2025-09-19 22:57:35 +08:00
committed by GitHub
parent f85035bd01
commit 9712e2354b
27 changed files with 2101 additions and 639 deletions

View File

@@ -1,8 +1,17 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { readFile, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import postgres from 'postgres';
import { z } from 'zod';
const DATABASE_URL =
process.env.DATABASE_URL ||
'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const sql = postgres(DATABASE_URL, {
prepare: false,
});
interface DatabaseFunction {
name: string;
parameters: Array<{
@@ -30,6 +39,58 @@ interface SchemaFile {
topic: string;
}
interface ProjectTable {
name: string;
schema: string;
sourceFile: string;
topic: string;
}
interface TableColumn {
name: string;
type: string;
nullable: boolean;
defaultValue?: string;
isPrimaryKey: boolean;
isForeignKey: boolean;
referencedTable?: string;
referencedColumn?: string;
}
interface TableIndex {
name: string;
columns: string[];
unique: boolean;
type: string;
definition: string;
}
interface TableForeignKey {
name: string;
columns: string[];
referencedTable: string;
referencedColumns: string[];
onDelete?: string;
onUpdate?: string;
}
interface TableInfo {
name: string;
schema: string;
sourceFile: string;
topic: string;
columns: TableColumn[];
foreignKeys: TableForeignKey[];
indexes: TableIndex[];
createStatement?: string;
}
interface EnumInfo {
name: string;
values: string[];
sourceFile: string;
}
export class DatabaseTool {
static async getSchemaFiles(): Promise<SchemaFile[]> {
const schemasPath = join(
@@ -95,7 +156,21 @@ export class DatabaseTool {
functionName: string,
): Promise<DatabaseFunction> {
const functions = await this.getFunctions();
const func = functions.find((f) => f.name === functionName);
// Extract just the function name if schema prefix is provided (e.g., "public.has_permission" -> "has_permission")
const nameParts = functionName.split('.');
const cleanFunctionName = nameParts[nameParts.length - 1];
const providedSchema = nameParts.length > 1 ? nameParts[0] : 'public';
// Try to find by exact name first, then by cleaned name and schema
let func = functions.find((f) => f.name === functionName);
if (!func) {
// Match by function name and schema (defaulting to public if no schema provided)
func = functions.find(
(f) => f.name === cleanFunctionName && f.schema === providedSchema,
);
}
if (!func) {
throw new Error(`Function "${functionName}" not found`);
@@ -108,12 +183,43 @@ export class DatabaseTool {
const allFunctions = await this.getFunctions();
const searchTerm = query.toLowerCase();
// Extract schema and function name from search query if provided
const nameParts = query.split('.');
const cleanSearchTerm = nameParts[nameParts.length - 1].toLowerCase();
const searchSchema =
nameParts.length > 1 ? nameParts[0].toLowerCase() : null;
return allFunctions.filter((func) => {
const matchesName = func.name.toLowerCase().includes(cleanSearchTerm);
const matchesFullName = func.name.toLowerCase().includes(searchTerm);
const matchesSchema = searchSchema
? func.schema.toLowerCase() === searchSchema
: true;
const matchesDescription = func.description
.toLowerCase()
.includes(searchTerm);
const matchesPurpose = func.purpose.toLowerCase().includes(searchTerm);
const matchesReturnType = func.returnType
.toLowerCase()
.includes(searchTerm);
// If schema is specified in query, must match both name and schema
if (searchSchema) {
return (matchesName || matchesFullName) && matchesSchema;
}
// Otherwise, match on any field
return (
func.name.toLowerCase().includes(searchTerm) ||
func.description.toLowerCase().includes(searchTerm) ||
func.purpose.toLowerCase().includes(searchTerm) ||
func.returnType.toLowerCase().includes(searchTerm)
matchesName ||
matchesFullName ||
matchesDescription ||
matchesPurpose ||
matchesReturnType
);
});
}
@@ -158,6 +264,262 @@ export class DatabaseTool {
);
}
static async getAllProjectTables(): Promise<ProjectTable[]> {
const schemaFiles = await this.getSchemaFiles();
const tables: ProjectTable[] = [];
for (const file of schemaFiles) {
const content = await readFile(file.path, 'utf8');
const extractedTables = this.extractTablesWithSchema(content);
for (const table of extractedTables) {
tables.push({
name: table.name,
schema: table.schema || 'public',
sourceFile: file.name,
topic: file.topic,
});
}
}
return tables;
}
static async getAllEnums(): Promise<Record<string, EnumInfo>> {
try {
// Try to get live enums from database first
const liveEnums = await this.getEnumsFromDB();
if (Object.keys(liveEnums).length > 0) {
return liveEnums;
}
// Fallback to schema files
const enumContent = await this.getSchemaContent('01-enums.sql');
return this.parseEnums(enumContent);
} catch (error) {
return {};
}
}
static async getTableInfo(
schema: string,
tableName: string,
): Promise<TableInfo> {
const schemaFiles = await this.getSchemaFiles();
for (const file of schemaFiles) {
const content = await readFile(file.path, 'utf8');
const tableDefinition = this.extractTableDefinition(
content,
schema,
tableName,
);
if (tableDefinition) {
// Enhance with live database info
const liveColumns = await this.getTableColumnsFromDB(schema, tableName);
const liveForeignKeys = await this.getTableForeignKeysFromDB(
schema,
tableName,
);
const liveIndexes = await this.getTableIndexesFromDB(schema, tableName);
return {
name: tableName,
schema: schema,
sourceFile: file.name,
topic: file.topic,
columns:
liveColumns.length > 0
? liveColumns
: this.parseColumns(tableDefinition),
foreignKeys:
liveForeignKeys.length > 0
? liveForeignKeys
: this.parseForeignKeys(tableDefinition),
indexes:
liveIndexes.length > 0
? liveIndexes
: this.parseIndexes(content, tableName),
createStatement: tableDefinition,
};
}
}
throw new Error(`Table ${schema}.${tableName} not found in schema files`);
}
static async getTableColumnsFromDB(
schema: string,
tableName: string,
): Promise<TableColumn[]> {
try {
const columns = await sql`
SELECT
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key,
CASE WHEN fk.column_name IS NOT NULL THEN true ELSE false END as is_foreign_key,
fk.foreign_table_name as referenced_table,
fk.foreign_column_name as referenced_column
FROM information_schema.columns c
LEFT JOIN (
SELECT ku.table_name, ku.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage ku
ON tc.constraint_name = ku.constraint_name
AND tc.table_schema = ku.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = ${schema}
) pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name
LEFT JOIN (
SELECT
ku.table_name,
ku.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 ku
ON tc.constraint_name = ku.constraint_name
AND tc.table_schema = ku.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = ${schema}
) fk ON c.table_name = fk.table_name AND c.column_name = fk.column_name
WHERE c.table_schema = ${schema}
AND c.table_name = ${tableName}
ORDER BY c.ordinal_position
`;
return columns.map((col) => ({
name: col.column_name,
type: col.data_type,
nullable: col.is_nullable === 'YES',
defaultValue: col.column_default,
isPrimaryKey: col.is_primary_key,
isForeignKey: col.is_foreign_key,
referencedTable: col.referenced_table,
referencedColumn: col.referenced_column,
}));
} catch (error) {
console.error(error);
return [];
}
}
static async getTableForeignKeysFromDB(
schema: string,
tableName: string,
): Promise<TableForeignKey[]> {
try {
const foreignKeys = await sql`
SELECT
tc.constraint_name,
string_agg(kcu.column_name, ',' ORDER BY kcu.ordinal_position) as columns,
ccu.table_name AS foreign_table_name,
string_agg(ccu.column_name, ',' ORDER BY kcu.ordinal_position) as foreign_columns,
rc.delete_rule,
rc.update_rule
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = ${schema}
AND tc.table_name = ${tableName}
GROUP BY tc.constraint_name, ccu.table_name, rc.delete_rule, rc.update_rule
`;
return foreignKeys.map((fk: any) => ({
name: fk.constraint_name,
columns: fk.columns.split(','),
referencedTable: fk.foreign_table_name,
referencedColumns: fk.foreign_columns.split(','),
onDelete: fk.delete_rule,
onUpdate: fk.update_rule,
}));
} catch (error) {
return [];
}
}
static async getTableIndexesFromDB(
schema: string,
tableName: string,
): Promise<TableIndex[]> {
try {
const indexes = await sql`
SELECT
i.indexname,
i.indexdef,
ix.indisunique as is_unique,
string_agg(a.attname, ',' ORDER BY a.attnum) as columns
FROM pg_indexes i
JOIN pg_class c ON c.relname = i.tablename
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_index ix ON ix.indexrelid = (
SELECT oid FROM pg_class WHERE relname = i.indexname
)
JOIN pg_attribute a ON a.attrelid = c.oid
AND a.attnum = ANY(ix.indkey)
WHERE n.nspname = ${schema}
AND i.tablename = ${tableName}
AND i.indexname NOT LIKE '%_pkey'
GROUP BY i.indexname, i.indexdef, ix.indisunique
ORDER BY i.indexname
`;
return indexes.map((idx) => ({
name: idx.indexname,
columns: idx.columns.split(','),
unique: idx.is_unique,
type: 'btree', // Default, could be enhanced
definition: idx.indexdef,
}));
} catch (error) {
console.error(error);
return [];
}
}
static async getEnumsFromDB(): Promise<Record<string, EnumInfo>> {
try {
const enums = await sql`
SELECT
t.typname as enum_name,
array_agg(e.enumlabel ORDER BY e.enumsortorder) as enum_values
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public'
GROUP BY t.typname
ORDER BY t.typname
`;
const result: Record<string, EnumInfo> = {};
for (const enumData of enums) {
result[enumData.enum_name] = {
name: enumData.enum_name,
values: enumData.enum_values,
sourceFile: 'database', // Live from DB
};
}
return result;
} catch (error) {
return {};
}
}
private static extractFunctionsFromContent(
content: string,
sourceFile: string,
@@ -328,6 +690,32 @@ export class DatabaseTool {
return [...new Set(tables)]; // Remove duplicates
}
private static extractTablesWithSchema(content: string): Array<{
name: string;
schema: string;
}> {
const tables: Array<{ name: string; schema: string }> = [];
const tableRegex =
/create\s+table\s+(?:if\s+not\s+exists\s+)?(?:([a-zA-Z_][a-zA-Z0-9_]*)\.)?([a-zA-Z_][a-zA-Z0-9_]*)/gi;
let match;
while ((match = tableRegex.exec(content)) !== null) {
if (match[2]) {
tables.push({
schema: match[1] || 'public',
name: match[2],
});
}
}
return tables.filter(
(table, index, arr) =>
arr.findIndex(
(t) => t.name === table.name && t.schema === table.schema,
) === index,
);
}
private static extractFunctionNames(content: string): string[] {
const functions: string[] = [];
const functionRegex =
@@ -361,6 +749,176 @@ export class DatabaseTool {
return [...new Set(dependencies)]; // Remove duplicates
}
private static extractTableDefinition(
content: string,
schema: string,
tableName: string,
): string | null {
const tableRegex = new RegExp(
`create\\s+table\\s+(?:if\\s+not\\s+exists\\s+)?(?:${schema}\\.)?${tableName}\\s*\\([^;]*?\\);`,
'gis',
);
const match = content.match(tableRegex);
return match ? match[0] : null;
}
private static parseColumns(tableDefinition: string): TableColumn[] {
const columns: TableColumn[] = [];
// Extract the content between parentheses
const contentMatch = tableDefinition.match(/\(([\s\S]*)\)/);
if (!contentMatch) return columns;
const content = contentMatch[1];
// Split by commas, but be careful of nested structures
const lines = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line);
for (const line of lines) {
if (
line.startsWith('constraint') ||
line.startsWith('primary key') ||
line.startsWith('foreign key')
) {
continue; // Skip constraint definitions
}
// Parse column definition: name type [constraints]
const columnMatch = line.match(
/^([a-zA-Z_][a-zA-Z0-9_]*)\s+([^,\s]+)(?:\s+(.*))?/,
);
if (columnMatch) {
const [, name, type, constraints = ''] = columnMatch;
columns.push({
name,
type: type.replace(/,$/, ''), // Remove trailing comma
nullable: !constraints.includes('not null'),
defaultValue: this.extractDefault(constraints),
isPrimaryKey: constraints.includes('primary key'),
isForeignKey: constraints.includes('references'),
referencedTable: this.extractReferencedTable(constraints),
referencedColumn: this.extractReferencedColumn(constraints),
});
}
}
return columns;
}
private static extractDefault(constraints: string): string | undefined {
const defaultMatch = constraints.match(/default\s+([^,\s]+)/i);
return defaultMatch ? defaultMatch[1] : undefined;
}
private static extractReferencedTable(
constraints: string,
): string | undefined {
const refMatch = constraints.match(
/references\s+([a-zA-Z_][a-zA-Z0-9_]*)/i,
);
return refMatch ? refMatch[1] : undefined;
}
private static extractReferencedColumn(
constraints: string,
): string | undefined {
const refMatch = constraints.match(
/references\s+[a-zA-Z_][a-zA-Z0-9_]*\s*\(([^)]+)\)/i,
);
return refMatch ? refMatch[1].trim() : undefined;
}
private static parseForeignKeys(tableDefinition: string): TableForeignKey[] {
const foreignKeys: TableForeignKey[] = [];
// Match foreign key constraints
const fkRegex =
/foreign\s+key\s*\(([^)]+)\)\s*references\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]+)\)(?:\s+on\s+delete\s+([a-z\s]+))?(?:\s+on\s+update\s+([a-z\s]+))?/gi;
let match;
while ((match = fkRegex.exec(tableDefinition)) !== null) {
const [
,
columns,
referencedTable,
referencedColumns,
onDelete,
onUpdate,
] = match;
foreignKeys.push({
name: `fk_${referencedTable}_${columns.replace(/\s/g, '')}`,
columns: columns.split(',').map((col) => col.trim()),
referencedTable,
referencedColumns: referencedColumns
.split(',')
.map((col) => col.trim()),
onDelete: onDelete?.trim(),
onUpdate: onUpdate?.trim(),
});
}
return foreignKeys;
}
private static parseIndexes(
content: string,
tableName: string,
): TableIndex[] {
const indexes: TableIndex[] = [];
// Match CREATE INDEX statements
const indexRegex = new RegExp(
`create\\s+(?:unique\\s+)?index\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s+on\\s+(?:public\\.)?${tableName}\\s*\\(([^)]+)\\)`,
'gi',
);
let match;
while ((match = indexRegex.exec(content)) !== null) {
const [fullMatch, indexName, columns] = match;
indexes.push({
name: indexName,
columns: columns.split(',').map((col) => col.trim()),
unique: fullMatch.toLowerCase().includes('unique'),
type: 'btree', // Default type
definition: fullMatch,
});
}
return indexes;
}
private static parseEnums(content: string): Record<string, EnumInfo> {
const enums: Record<string, EnumInfo> = {};
// Match CREATE TYPE ... AS ENUM
const enumRegex =
/create\s+type\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+as\s+enum\s*\(([^)]+)\)/gi;
let match;
while ((match = enumRegex.exec(content)) !== null) {
const [, enumName, values] = match;
const enumValues = values
.split(',')
.map((value) => value.trim().replace(/['"]/g, ''))
.filter((value) => value);
enums[enumName] = {
name: enumName,
values: enumValues,
sourceFile: '01-enums.sql',
};
}
return enums;
}
private static determineTopic(fileName: string, content: string): string {
// Map file names to topics
const fileTopicMap: Record<string, string> = {
@@ -426,6 +984,14 @@ export function registerDatabaseTools(server: McpServer) {
createSearchFunctionsTool(server);
}
export function registerDatabaseResources(server: McpServer) {
createDatabaseSummaryTool(server);
createDatabaseTablesListTool(server);
createGetTableInfoTool(server);
createGetEnumInfoTool(server);
createGetAllEnumsTool(server);
}
function createGetSchemaFilesTool(server: McpServer) {
return server.tool(
'get_schema_files',
@@ -704,3 +1270,192 @@ function createGetSchemaBySectionTool(server: McpServer) {
},
);
}
function createDatabaseSummaryTool(server: McpServer) {
return server.tool(
'get_database_summary',
'📊 Get comprehensive database overview with tables, enums, and functions',
async () => {
const tables = await DatabaseTool.getAllProjectTables();
const enums = await DatabaseTool.getAllEnums();
const functions = await DatabaseTool.getFunctions();
const summary = {
overview: {
totalTables: tables.length,
totalEnums: Object.keys(enums).length,
totalFunctions: functions.length,
},
tables: tables.map((t) => ({
name: t.name,
schema: t.schema,
topic: t.topic,
sourceFile: t.sourceFile,
})),
enums: Object.entries(enums).map(([name, info]) => ({
name,
values: info.values,
sourceFile: info.sourceFile,
})),
functions: functions.map((f) => ({
name: f.name,
schema: f.schema,
purpose: f.purpose,
sourceFile: f.sourceFile,
})),
tablesByTopic: tables.reduce(
(acc, table) => {
if (!acc[table.topic]) acc[table.topic] = [];
acc[table.topic].push(table.name);
return acc;
},
{} as Record<string, string[]>,
),
};
return {
content: [
{
type: 'text',
text: `📊 DATABASE OVERVIEW\n\n${JSON.stringify(summary, null, 2)}`,
},
],
};
},
);
}
function createDatabaseTablesListTool(server: McpServer) {
return server.tool(
'get_database_tables',
'📋 Get list of all project-defined database tables',
async () => {
const tables = await DatabaseTool.getAllProjectTables();
return {
content: [
{
type: 'text',
text: `📋 PROJECT TABLES\n\n${JSON.stringify(tables, null, 2)}`,
},
],
};
},
);
}
function createGetTableInfoTool(server: McpServer) {
return server.tool(
'get_table_info',
'🗂️ Get detailed table schema with columns, foreign keys, and indexes',
{
state: z.object({
schema: z.string().default('public'),
tableName: z.string(),
}),
},
async ({ state }) => {
try {
const tableInfo = await DatabaseTool.getTableInfo(
state.schema,
state.tableName,
);
return {
content: [
{
type: 'text',
text: `🗂️ TABLE: ${state.schema}.${state.tableName}\n\n${JSON.stringify(tableInfo, null, 2)}`,
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `❌ Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
},
);
}
function createGetEnumInfoTool(server: McpServer) {
return server.tool(
'get_enum_info',
'🏷️ Get enum type definition with all possible values',
{
state: z.object({
enumName: z.string(),
}),
},
async ({ state }) => {
try {
const enums = await DatabaseTool.getAllEnums();
const enumInfo = enums[state.enumName];
if (!enumInfo) {
return {
content: [
{
type: 'text',
text: `❌ Enum "${state.enumName}" not found. Available enums: ${Object.keys(enums).join(', ')}`,
},
],
};
}
return {
content: [
{
type: 'text',
text: `🏷️ ENUM: ${state.enumName}\n\n${JSON.stringify(enumInfo, null, 2)}`,
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `❌ Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
},
);
}
function createGetAllEnumsTool(server: McpServer) {
return server.tool(
'get_all_enums',
'🏷️ Get all enum types and their values',
async () => {
try {
const enums = await DatabaseTool.getAllEnums();
return {
content: [
{
type: 'text',
text: `🏷️ ALL ENUMS\n\n${JSON.stringify(enums, null, 2)}`,
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `❌ Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
},
);
}