Model Context Protocol PostgreSQL Server
by endaoment
Verified
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import pg from "pg";
// Import and configure dotenv to load .env file
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import os from 'os';
// Get the directory of this file
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load environment variables from .env file
dotenv.config({ path: path.join(__dirname, '.env') });
// Parse command line arguments
const parseArgs = () => {
const args = process.argv.slice(2);
const options = {
credentialsVar: 'DB_CREDENTIALS', // Default value
silent: true // Default to silent mode
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--credentials-var' || args[i] === '-c') {
if (i + 1 < args.length) {
options.credentialsVar = args[i + 1];
i++; // Skip the next argument as it's the value
} else {
console.error('Error: --credentials-var flag requires a value');
process.exit(1);
}
} else if (args[i] === '--verbose' || args[i] === '-v') {
options.silent = false;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: node server.js [options]
Options:
-c, --credentials-var VAR Specify environment variable name containing DB credentials
(default: DB_CREDENTIALS)
-v, --verbose Enable console logs (silent by default)
-h, --help Show this help message
Environment Variables:
The specified environment variable (or DB_CREDENTIALS as fallback) should contain
a JSON string with the following structure:
{
"DB_USER": "username",
"DB_PASSWORD": "password",
"DB_HOST": "hostname",
"DB_PORT": "5432",
"DB_NAME": "database_name"
}
Fallback Logic:
If the environment variable is not found in the .env file, the server will
look for it in shell config files (~/.zshrc, ~/.bashrc, ~/.bash_profile, ~/.profile).
This is especially useful for Cursor MCP environments where shell config files
are not automatically sourced.
Examples:
1. Using environment variable:
node server.js --credentials-var MY_CUSTOM_DB_CREDS
2. Using .env file (default):
node server.js
`);
process.exit(0);
}
}
return options;
};
// Check if we're being run under Cursor MCP
const isCursorMCP = process.env.CURSOR_MCP === '1';
const options = parseArgs();
// Create custom logger to respect silent mode
const logger = {
log: (...args) => {
if (!options.silent) {
console.log(...args);
}
},
error: (...args) => {
// Always show errors, even in silent mode
console.error(...args);
}
};
// Special handling for Cursor environment
if (isCursorMCP) {
logger.log('Running in Cursor MCP environment');
}
// Function to parse DB_CREDENTIALS from shell config files
const parseShellConfigForCredentials = () => {
// List of common shell config files to check
const configFiles = [
'.zshrc',
'.bashrc',
'.bash_profile',
'.profile'
];
for (const configFile of configFiles) {
try {
const configPath = path.join(os.homedir(), configFile);
if (!fs.existsSync(configPath)) {
logger.log(`~/${configFile} file not found`);
continue;
}
const configContent = fs.readFileSync(configPath, 'utf8');
// Enhanced regex to handle different export formats:
// - export VAR='value'
// - export VAR="value"
// - export VAR=value
const patterns = [
// Single quotes
new RegExp(`export\\s+(${options.credentialsVar}|[A-Z_]+DB[A-Z_]*CREDENTIALS[A-Z_]*)\\s*=\\s*'(.+?)'`, 's'),
// Double quotes
new RegExp(`export\\s+(${options.credentialsVar}|[A-Z_]+DB[A-Z_]*CREDENTIALS[A-Z_]*)\\s*=\\s*"(.+?)"`, 's'),
// No quotes
new RegExp(`export\\s+(${options.credentialsVar}|[A-Z_]+DB[A-Z_]*CREDENTIALS[A-Z_]*)\\s*=\\s*({.+?})`, 's')
];
for (const regex of patterns) {
const match = configContent.match(regex);
if (match && match.length >= 3) {
const varName = match[1];
const credentialsJson = match[2];
logger.log(`Found ${varName} in ~/${configFile}`);
try {
const credentials = JSON.parse(credentialsJson);
return credentials;
} catch (parseError) {
logger.error(`Failed to parse JSON from ${varName} in ~/${configFile}: ${parseError.message}`);
// Continue trying other patterns or files
}
}
}
} catch (error) {
logger.error(`Error reading ~/${configFile}: ${error.message}`);
// Continue with next config file
}
}
return null;
};
// Parse DB credentials from environment variable, .env file, or shell config files
let dbCredentials;
try {
// Try to get credentials from environment variable
const credentialsJson = process.env[options.credentialsVar];
if (!credentialsJson) {
logger.log(`${options.credentialsVar} environment variable not found. Checking shell config files as fallback...`);
// Try to get credentials from shell config files
dbCredentials = parseShellConfigForCredentials();
if (!dbCredentials) {
throw new Error(`${options.credentialsVar} not found in environment variables or shell config files.`);
}
logger.log('Successfully parsed database credentials from shell config file');
} else {
try {
dbCredentials = JSON.parse(credentialsJson);
if (!options.silent) {
logger.log(`Successfully parsed database credentials from ${options.credentialsVar} environment variable`);
}
} catch (parseError) {
throw new Error(`Failed to parse JSON from ${options.credentialsVar}: ${parseError.message}. Ensure it contains valid JSON.`);
}
}
} catch (error) {
logger.error('Error with database credentials:', error.message);
logger.error('Make sure your .env file is set up correctly with the DB_CREDENTIALS variable,');
logger.error('or ensure DB_CREDENTIALS is properly exported in your shell config file (~/.zshrc, ~/.bashrc, etc.).');
logger.error('Run with --help for more information on the required format.');
process.exit(1);
}
// Validate required database credentials
const validateCredentials = () => {
const requiredFields = ['DB_USER', 'DB_PASSWORD', 'DB_HOST', 'DB_PORT', 'DB_NAME'];
const missingFields = requiredFields.filter(field => !dbCredentials[field]);
if (missingFields.length > 0) {
logger.error(`Error: Missing required fields in ${options.credentialsVar}: ${missingFields.join(', ')}`);
logger.error(`The ${options.credentialsVar} environment variable must contain a JSON object with these fields.`);
logger.error('Run with --help for more information on the required format.');
return false;
}
// Validate that port is a number
const port = parseInt(dbCredentials.DB_PORT, 10);
if (isNaN(port)) {
logger.error(`Error: DB_PORT must be a valid number, got: "${dbCredentials.DB_PORT}"`);
return false;
}
return true;
};
if (!validateCredentials()) {
logger.error('Database credentials validation failed. Exiting.');
process.exit(1);
}
// Database connection configuration
const config = {
user: dbCredentials.DB_USER,
password: dbCredentials.DB_PASSWORD,
host: dbCredentials.DB_HOST,
port: parseInt(dbCredentials.DB_PORT, 10),
database: dbCredentials.DB_NAME,
// Add connection timeout and retry settings
connectionTimeoutMillis: 10000,
idleTimeoutMillis: 30000,
max: 10, // Maximum number of clients in the pool
};
// Ensure localhost is using IPv4
if (config.host === 'localhost') {
config.host = '127.0.0.1';
logger.log('Changed localhost to 127.0.0.1 to ensure IPv4 connection');
}
// Log connection details (without password)
if (!options.silent) {
logger.log('Database configuration:', {
user: config.user,
host: config.host,
port: config.port,
database: config.database,
});
}
// Create a connection pool
const pool = new pg.Pool(config);
// Add event listeners for pool errors
pool.on('error', (err) => {
logger.error('Unexpected error on idle client', err);
});
// Create the MCP server
const server = new Server(
{
name: "cursor-mcp-postgres",
version: "0.1.0",
},
{
capabilities: {
resources: {},
tools: {},
},
},
);
// Construct a resource base URL for the database
const resourceBaseUrl = new URL(`postgres://${config.user}@${config.host}:${config.port}/${config.database}`);
resourceBaseUrl.protocol = "postgres:";
resourceBaseUrl.password = "";
const SCHEMA_PATH = "schema";
// Test database connection
async function testDatabaseConnection() {
let testClient = null;
try {
logger.log('Testing connection to PostgreSQL database...');
testClient = await pool.connect();
const result = await testClient.query('SELECT 1 AS connection_test');
logger.log('Database connection test successful:', result.rows[0]);
return true;
} catch (err) {
logger.error('Database connection test failed:', err.message);
// Provide more specific error messages based on error codes
if (err.code === 'ECONNREFUSED') {
logger.error('Connection refused. Please check if the database server is running and accessible.');
} else if (err.code === '28P01') {
logger.error('Authentication failed. Please check your database credentials.');
} else if (err.code === '3D000') {
logger.error('Database does not exist. Please check the database name.');
}
logger.error('Error details:', err);
// Don't exit immediately in Cursor environment to allow showing the error
if (!isCursorMCP) {
process.exit(1);
}
return false;
} finally {
if (testClient) {
testClient.release();
}
}
}
// Execute a query with retry logic
async function executeQueryWithRetry(sql, maxRetries = 3) {
let retries = 0;
let lastError = null;
while (retries < maxRetries) {
const client = await pool.connect();
try {
logger.log(`Executing query (attempt ${retries + 1}/${maxRetries}):`, sql);
const result = await client.query(sql);
return result;
} catch (err) {
lastError = err;
logger.error(`Query error (attempt ${retries + 1}/${maxRetries}):`, err.message);
// Check if this is a connection-related error that might be resolved by retrying
if (err.code === 'ECONNREFUSED' || err.code === '57P01' || err.code === '08006' || err.code === '08001') {
retries++;
await new Promise(resolve => setTimeout(resolve, 1000 * retries)); // Exponential backoff
} else {
// For other errors, don't retry
throw err;
}
} finally {
client.release();
}
}
// If we've exhausted all retries
throw lastError;
}
// Handler for listing database resources (tables)
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const client = await pool.connect();
try {
const result = await client.query(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'",
);
return {
resources: result.rows.map((row) => ({
uri: new URL(`${row.table_name}/${SCHEMA_PATH}`, resourceBaseUrl).href,
mimeType: "application/json",
name: `"${row.table_name}" database schema`,
})),
};
} catch (error) {
logger.error('Error listing resources:', error.message);
throw error;
} finally {
client.release();
}
});
// Handler for reading resource details (table schema)
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const resourceUrl = new URL(request.params.uri);
const pathComponents = resourceUrl.pathname.split("/");
const schema = pathComponents.pop();
const tableName = pathComponents.pop();
if (schema !== SCHEMA_PATH) {
throw new Error("Invalid resource URI");
}
const client = await pool.connect();
try {
const result = await client.query(
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1",
[tableName],
);
return {
contents: [
{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(result.rows, null, 2),
},
],
};
} catch (error) {
logger.error('Error reading resource:', error.message);
throw error;
} finally {
client.release();
}
});
// Handler for listing available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "query",
description: "Run a SQL query against the PostgreSQL database",
inputSchema: {
type: "object",
properties: {
sql: { type: "string" },
},
required: ["sql"],
},
},
],
};
});
// Handler for executing tool calls (SQL queries)
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "query") {
// Use type assertion in a way that works in JavaScript
const sql = request.params.arguments?.sql;
if (!sql || typeof sql !== 'string') {
throw new Error("SQL query is required and must be a string");
}
try {
// Use our retry logic for query execution
const result = await executeQueryWithRetry(sql);
return {
content: [{ type: "text", text: JSON.stringify(result.rows, null, 2) }],
isError: false,
};
} catch (error) {
logger.error('Query execution error:', error.message);
return {
content: [{ type: "text", text: `Error executing query: ${error.message}` }],
isError: true,
};
}
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
// Main function to run the server
async function runServer() {
try {
// Test database connection before starting
const connectionSuccess = await testDatabaseConnection();
// In Cursor, we proceed even if the connection test fails to show errors
if (!isCursorMCP || connectionSuccess) {
logger.log('Starting MCP server for PostgreSQL...');
// Create the transport
const transport = new StdioServerTransport();
try {
// Connect to the transport
await server.connect(transport);
logger.log('MCP server connected and ready to use');
} catch (transportError) {
logger.error('Failed to connect to MCP transport:', transportError.message);
if (!isCursorMCP) {
process.exit(1);
}
}
}
} catch (error) {
logger.error('Server startup error:', error.message);
logger.error('Error details:', error);
if (!isCursorMCP) {
process.exit(1);
}
}
}
// Handle graceful shutdown
process.on('SIGINT', async () => {
logger.log('Closing database connection pool...');
try {
await pool.end();
logger.log('Database connection pool closed.');
} catch (err) {
logger.error('Error closing pool:', err.message);
}
process.exit(0);
});
// Start the server
runServer().catch(err => {
logger.error('Unhandled error:', err);
logger.error('Error details:', err);
if (!isCursorMCP) {
process.exit(1);
}
});