index.ts•12.2 kB
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ErrorCode,
  ListToolsRequestSchema,
  McpError,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
  Resource,
  ResourceContents,
} from '@modelcontextprotocol/sdk/types.js';
import pg from 'pg';
// Database connection configuration from environment variables
const DB_CONFIG = {
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  ssl: {
    rejectUnauthorized: false, // Disable certificate validation to handle self-signed certificates
  },
};
// Validate that all required environment variables are present
if (!DB_CONFIG.host || !DB_CONFIG.database || !DB_CONFIG.user || !DB_CONFIG.password) {
  throw new Error('Missing required database configuration environment variables');
}
// Regular expressions for validating read-only SQL queries
const READ_ONLY_PATTERNS = [
  /^\s*SELECT\s/i,
  /^\s*WITH\s/i,
  /^\s*SHOW\s/i,
  /^\s*DESCRIBE\s/i,
  /^\s*EXPLAIN\s/i,
];
const WRITE_PATTERNS = [
  /^\s*INSERT\s/i,
  /^\s*UPDATE\s/i,
  /^\s*DELETE\s/i,
  /^\s*DROP\s/i,
  /^\s*CREATE\s/i,
  /^\s*ALTER\s/i,
  /^\s*TRUNCATE\s/i,
  /^\s*GRANT\s/i,
  /^\s*REVOKE\s/i,
];
/**
 * Validates if a SQL query is read-only
 * @param sql The SQL query to validate
 * @returns true if the query is read-only, false otherwise
 */
function isReadOnlyQuery(sql: string): boolean {
  // Check if the query matches any read-only pattern
  const isReadOnly = READ_ONLY_PATTERNS.some(pattern => pattern.test(sql));
  
  // Check if the query matches any write pattern
  const isWrite = WRITE_PATTERNS.some(pattern => pattern.test(sql));
  
  // A query is read-only if it matches a read-only pattern and doesn't match any write pattern
  return isReadOnly && !isWrite;
}
/**
 * Validates the SQL query arguments
 * @param args The arguments to validate
 * @returns true if the arguments are valid, false otherwise
 */
const isValidQueryArgs = (args: any): args is { sql: string } =>
  typeof args === 'object' &&
  args !== null &&
  typeof args.sql === 'string';
class PostgresServer {
  private server: Server;
  private pool: pg.Pool;
  constructor() {
    // Initialize the MCP server
    this.server = new Server(
      {
        name: 'aws-postgres-mcp-server',
        version: '1.1.0',
      },
      {
        capabilities: {
          tools: {},
          resources: {}, // <-- Add this empty object to enable resources
        },
      }
    );
    // Initialize the PostgreSQL connection pool
    this.pool = new pg.Pool(DB_CONFIG);
    // Set up the tool handlers
    this.setupToolHandlers();
    // Set up the resource handlers
    this.setupResourceHandlers(); // <-- Add this call
    // Error handling
    this.server.onerror = (error) => console.error('[MCP Error]', error);
    process.on('SIGINT', async () => {
      await this.pool.end();
      await this.server.close();
      process.exit(0);
    });
  }
  /**
   * Sets up the tool handlers for the MCP server
   */
  private setupToolHandlers() {
    // Define the available tools
    this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: 'query',
          description: 'Run a read-only SQL query against the AWS PostgreSQL database',
          inputSchema: {
            type: 'object',
            properties: {
              sql: {
                type: 'string',
                description: 'The SQL query to execute (must be read-only)',
              },
            },
            required: ['sql'],
          },
        },
      ],
    }));
    // Handle tool calls
    this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
      if (request.params.name !== 'query') {
        throw new McpError(
          ErrorCode.MethodNotFound,
          `Unknown tool: ${request.params.name}`
        );
      }
      // Validate the arguments
      if (!isValidQueryArgs(request.params.arguments)) {
        throw new McpError(
          ErrorCode.InvalidParams,
          'Invalid query arguments: expected { sql: string }'
        );
      }
      const sql = request.params.arguments.sql;
      // Validate that the query is read-only
      if (!isReadOnlyQuery(sql)) {
        return {
          content: [
            {
              type: 'text',
              text: 'Error: Only read-only queries are allowed. Your query appears to be a write operation or contains disallowed statements.',
            },
          ],
          isError: true,
        };
      }
      try {
        // Execute the query
        const result = await this.pool.query(sql);
        
        // Format the result as JSON
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify({
                rowCount: result.rowCount,
                rows: result.rows,
                fields: result.fields.map(field => ({
                  name: field.name,
                  dataTypeID: field.dataTypeID,
                })),
              }, null, 2),
            },
          ],
        };
      } catch (error) {
        // Handle database errors
        const errorMessage = error instanceof Error ? error.message : String(error);
        return {
          content: [
            {
              type: 'text',
              text: `Database error: ${errorMessage}`,
            },
          ],
          isError: true,
        };
      }
    });
  }
  /**
   * Sets up the resource handlers for the MCP server
   */
  private setupResourceHandlers() {
    const dbName = DB_CONFIG.database || 'unknown_db';
    const schemasToExpose = ['minrights', 'public', 'spatial', 'ed_data', 'data', 'ose'];
    const baseUriSchema = `aws-pg://${dbName}/schema`; // URI for schema resources
    // Handler for listing available top-level resources (schemas)
    this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
      const resources: Resource[] = schemasToExpose.map(schemaName => ({
        uri: `${baseUriSchema}/${schemaName}`, // URI points to the schema itself
        name: `Schema: ${schemaName}`,
        description: `Browse tables within the ${schemaName} schema of the ${dbName} database.`,
        mimeType: 'application/json', // Indicate that reading returns JSON list of tables
      }));
      return { resources };
    });
    // Handler for reading a specific resource (either a schema or a table)
    this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
      let uri = request.params.uri; // Use 'let' so we can modify it
      // Strip leading '@' if present (added by some clients like Cline)
      if (uri.startsWith('@')) {
        uri = uri.substring(1);
      }
      const schemaUriPrefix = `${baseUriSchema}/`;
      // Check if it's a schema URI (e.g., .../schema/public)
      if (uri.startsWith(schemaUriPrefix) && !uri.includes('/table/')) {
        const schemaName = uri.substring(schemaUriPrefix.length);
        if (schemasToExpose.includes(schemaName)) {
          try {
            // Query for tables within this schema
            const tableQuery = `
              SELECT table_name
              FROM information_schema.tables
              WHERE table_schema = $1
              ORDER BY table_name;
            `;
            const tableResult = await this.pool.query(tableQuery, [schemaName]);
            // Return a list of *table* resources
            const tableResources: Resource[] = tableResult.rows.map(row => {
              const tableName = row.table_name;
              return {
                uri: `${uri}/table/${tableName}`, // Construct table URI
                name: tableName,
                description: `Schema definition for table ${schemaName}.${tableName}`,
                mimeType: 'text/plain', // Content will be table schema
              };
            });
            // MCP allows ReadResource to return multiple Resource definitions
            // We wrap them in a ResourceContents object where the 'resources' field holds the list.
            // A client would interpret this as the content of the schema resource *being* the list of table resources.
            // const contents: ResourceContents[] = [{ uri: uri, resources: tableResources }];
            // return { contents };
            // --- New Approach: Return table list as JSON text --- //
            const tableListJson = JSON.stringify(tableResources, null, 2); // Pretty-print JSON
            const contents: ResourceContents[] = [
              {
                uri: uri, // URI of the schema resource itself
                mimeType: 'application/json',
                text: tableListJson, // Return the list as JSON text
              },
            ];
            return { contents };
          } catch (error) {
            const errorMessage = error instanceof Error ? error.message : String(error);
            throw new McpError(ErrorCode.InternalError, `Failed to list tables for schema ${schemaName}: ${errorMessage}`);
          }
        } else {
          throw new McpError(ErrorCode.InvalidParams, `Schema not exposed: ${schemaName}`);
        }
      }
      // Check if it's a table URI (e.g., .../schema/public/table/users)
      const tableUriPattern = new RegExp(`^${schemaUriPrefix}([^/]+)/table/([^/]+)$`);
      const tableMatch = uri.match(tableUriPattern);
      if (tableMatch) {
        const schemaName = tableMatch[1];
        const tableName = tableMatch[2];
        if (schemasToExpose.includes(schemaName)) {
          try {
            // Query for column definitions of this table
            const columnQuery = `
              SELECT column_name, data_type, is_nullable
              FROM information_schema.columns
              WHERE table_schema = $1 AND table_name = $2
              ORDER BY ordinal_position;
            `;
            const columnResult = await this.pool.query(columnQuery, [schemaName, tableName]);
            if (columnResult.rows.length === 0) {
               throw new McpError(ErrorCode.InvalidParams, `Table not found or no columns: ${schemaName}.${tableName}`);
            }
            // Format the schema information
            let schemaText = `Schema for table: ${schemaName}.${tableName}\n\n`;
            schemaText += columnResult.rows.map(col =>
              `- ${col.column_name}: ${col.data_type} (${col.is_nullable === 'YES' ? 'NULLABLE' : 'NOT NULL'})`
            ).join('\n');
            const contents: ResourceContents[] = [
              {
                uri: uri,
                mimeType: 'text/plain',
                text: schemaText,
              },
            ];
            return { contents };
          } catch (error) {
             if (error instanceof McpError) throw error; // Re-throw known MCP errors
             const errorMessage = error instanceof Error ? error.message : String(error);
            throw new McpError(ErrorCode.InternalError, `Failed to read schema for table ${schemaName}.${tableName}: ${errorMessage}`);
          }
        } else {
           throw new McpError(ErrorCode.InvalidParams, `Schema not exposed: ${schemaName}`);
        }
      }
      // If URI format is not recognized
      throw new McpError(ErrorCode.InvalidParams, `Resource URI not found or format incorrect: ${uri}`);
    });
  }
  /**
   * Starts the MCP server
   */
  async run() {
    try {
      // Test the database connection
      const client = await this.pool.connect();
      console.error('Successfully connected to the PostgreSQL database');
      client.release();
      // Start the server
      const transport = new StdioServerTransport();
      await this.server.connect(transport);
      console.error('AWS PostgreSQL MCP server running on stdio');
    } catch (error) {
      console.error('Failed to start the server:', error);
      process.exit(1);
    }
  }
}
// Create and run the server
const server = new PostgresServer();
server.run().catch(console.error);