diff --git a/.gitignore b/.gitignore index 32d14dd..f796d38 100644 --- a/.gitignore +++ b/.gitignore @@ -184,3 +184,5 @@ devenv.local.nix .pre-commit-config.yaml *.sql .idea +!tests/db-sample-data/ +!tests/db-sample-data/*.sql diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32007a0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + # Sample PostgreSQL database with dvdrental sample data + sample-postgres: + image: postgres:16-alpine + container_name: sample-postgres-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: dvdrental + ports: + - "5432:5432" + volumes: + # Initialize with sample data from a SQL file + - ./test/db-sample-data:/docker-entrypoint-initdb.d + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - postgres-mcp-network + + # Postgres MCP Server + postgres-mcp-server: + build: + context: . + dockerfile: Dockerfile + container_name: postgres-mcp-server + ports: + - "8000:8000" + environment: + # Connection string to the sample database + - DATABASE_URL=postgresql://postgres:postgres@sample-postgres:5432/dvdrental + depends_on: + sample-postgres: + condition: service_healthy + networks: + - postgres-mcp-network + # Override the entrypoint to connect to our sample database with SSE transport for MCP Inspector + command: ["--access-mode=unrestricted", "--transport=sse", "postgresql://postgres:postgres@sample-postgres:5432/dvdrental"] + +volumes: + postgres-data: + driver: local + +networks: + postgres-mcp-network: + driver: bridge diff --git a/src/postgres_mcp/resource.py b/src/postgres_mcp/resource.py index 0a643fd..721d157 100644 --- a/src/postgres_mcp/resource.py +++ b/src/postgres_mcp/resource.py @@ -16,71 +16,21 @@ ResponseType = List[types.TextContent | types.ImageContent | types.EmbeddedResource] -def dynamically_register_resources(mcp_instance, database_name: Optional[str] = None): # type: ignore - """ - Register consolidated resource handlers with the MCP instance. - - Args: - mcp_instance: The FastMCP instance to register resources with - database_name: Optional specific database name. If None, registers dynamic resources. - """ - - if database_name: - logger.info(f"Registering static resources for database: {database_name}") - _register_static_resources(mcp_instance, database_name) - else: - logger.info("Registering dynamic resources with database name parameter") - _register_dynamic_resources(mcp_instance) - - -def _register_static_resources(mcp_instance, db_name: str): # type: ignore - """Register static resource paths for a specific database.""" - - tables = f"postgres://{db_name}/" - views = f"postgres://{db_name}/" - - tables_uri = tables + "{schema_name}/tables" - views_uri = views + "{schema_name}/views" - - logger.info(f"Registering static resource: {tables_uri}") - logger.info(f"Registering static resource: {views_uri}") - - @mcp_instance.resource(tables_uri) # type: ignore - async def get_database_tables_static(schema_name: str) -> ResponseType: - """ - Get comprehensive information about all tables in the configured database. - - Returns complete table information including schemas, columns with comments, - constraints, indexes, and statistics. - """ - return await _get_tables_impl(db_name, schema_name) - - @mcp_instance.resource(views_uri) # type: ignore - async def get_database_views_static(schema_name: str) -> ResponseType: - """ - Get comprehensive information about all views in the configured database. - - Returns complete view information including schemas, columns with comments, - view definitions, and dependencies. - """ - return await _get_views_impl(db_name, schema_name) - - -def _register_dynamic_resources(mcp_instance): # type: ignore - """Register dynamic resource paths with database name parameter.""" +def register_resource_templates(mcp_instance): # type: ignore + """Register resource handlers with the MCP instance using template URIs.""" tables_uri = "postgres://{database_name}/{schema_name}/tables" views_uri = "postgres://{database_name}/{schema_name}/views" databases_uri = "postgres://databases" schemas_uri = "postgres://{database_name}/schemas" - logger.info(f"Registering dynamic resource: {tables_uri}") - logger.info(f"Registering dynamic resource: {views_uri}") - logger.info(f"Registering dynamic resource: {databases_uri}") - logger.info(f"Registering dynamic resource: {schemas_uri}") + logger.info(f"Registering resource: {tables_uri}") + logger.info(f"Registering resource: {views_uri}") + logger.info(f"Registering resource: {databases_uri}") + logger.info(f"Registering resource: {schemas_uri}") @mcp_instance.resource(tables_uri) # type: ignore - async def get_database_tables_dynamic(database_name: str, schema_name: Optional[str] = None) -> ResponseType: + async def get_database_tables(database_name: str, schema_name: Optional[str] = None) -> ResponseType: """ Get comprehensive information about all tables in a specific database. @@ -94,7 +44,7 @@ async def get_database_tables_dynamic(database_name: str, schema_name: Optional[ return await _get_tables_impl(database_name, schema_name) @mcp_instance.resource(views_uri) # type: ignore - async def get_database_views_dynamic(database_name: str, schema_name: Optional[str] = None) -> ResponseType: + async def get_database_views(database_name: str, schema_name: Optional[str] = None) -> ResponseType: """ Get comprehensive information about all views in a specific database. @@ -108,7 +58,7 @@ async def get_database_views_dynamic(database_name: str, schema_name: Optional[s return await _get_views_impl(database_name, schema_name) @mcp_instance.resource(databases_uri) # type: ignore - async def get_all_databases_dynamic() -> ResponseType: + async def get_all_databases() -> ResponseType: """ List all databases in the PostgreSQL server. @@ -124,7 +74,7 @@ async def get_all_databases_dynamic() -> ResponseType: return await _get_databases_info_impl(None) @mcp_instance.resource(schemas_uri) # type: ignore - async def get_all_schemas_dynamic(database_name: str) -> ResponseType: + async def get_all_schemas(database_name: str) -> ResponseType: """ List all schemas in a specific PostgreSQL database. diff --git a/src/postgres_mcp/server.py b/src/postgres_mcp/server.py index 21091d6..90017d6 100644 --- a/src/postgres_mcp/server.py +++ b/src/postgres_mcp/server.py @@ -26,7 +26,7 @@ from .index.llm_opt import LLMOptimizerTool from .index.presentation import TextPresentation from .moldes.model import AccessMode -from .resource import dynamically_register_resources +from .resource import register_resource_templates from .resource import format_error_response from .resource import format_text_response from .sql import SafeSqlDriver @@ -441,7 +441,7 @@ async def main(): logger.info(f"Database name: {database_name}") # Register all MCP resource handlers - dynamically_register_resources(mcp, database_name) + register_resource_templates(mcp) # Initialize database connection pool try: diff --git a/tests/db-sample-data/01-init.sql b/tests/db-sample-data/01-init.sql new file mode 100644 index 0000000..1f7181c --- /dev/null +++ b/tests/db-sample-data/01-init.sql @@ -0,0 +1,175 @@ +-- Sample Data Initialization Script +-- This creates a simple e-commerce database with customers, products, and orders + +-- Create tables +CREATE TABLE IF NOT EXISTS customers ( + customer_id SERIAL PRIMARY KEY, + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + phone VARCHAR(20), + city VARCHAR(50), + country VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS categories ( + category_id SERIAL PRIMARY KEY, + category_name VARCHAR(100) NOT NULL, + description TEXT +); + +CREATE TABLE IF NOT EXISTS products ( + product_id SERIAL PRIMARY KEY, + product_name VARCHAR(200) NOT NULL, + category_id INTEGER REFERENCES categories(category_id), + price DECIMAL(10, 2) NOT NULL, + stock_quantity INTEGER DEFAULT 0, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS orders ( + order_id SERIAL PRIMARY KEY, + customer_id INTEGER REFERENCES customers(customer_id), + order_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + total_amount DECIMAL(10, 2), + status VARCHAR(20) DEFAULT 'pending', + shipping_address TEXT +); + +CREATE TABLE IF NOT EXISTS order_items ( + order_item_id SERIAL PRIMARY KEY, + order_id INTEGER REFERENCES orders(order_id), + product_id INTEGER REFERENCES products(product_id), + quantity INTEGER NOT NULL, + unit_price DECIMAL(10, 2) NOT NULL +); + +-- Insert sample data + +-- Customers +INSERT INTO customers (first_name, last_name, email, phone, city, country) VALUES + ('John', 'Doe', 'john.doe@email.com', '+1-555-0101', 'New York', 'USA'), + ('Jane', 'Smith', 'jane.smith@email.com', '+1-555-0102', 'Los Angeles', 'USA'), + ('Bob', 'Johnson', 'bob.johnson@email.com', '+1-555-0103', 'Chicago', 'USA'), + ('Alice', 'Williams', 'alice.williams@email.com', '+44-20-1234', 'London', 'UK'), + ('Charlie', 'Brown', 'charlie.brown@email.com', '+49-30-5678', 'Berlin', 'Germany'), + ('Diana', 'Davis', 'diana.davis@email.com', '+33-1-9876', 'Paris', 'France'), + ('Eve', 'Martinez', 'eve.martinez@email.com', '+34-91-5432', 'Madrid', 'Spain'), + ('Frank', 'Garcia', 'frank.garcia@email.com', '+1-555-0104', 'Miami', 'USA'), + ('Grace', 'Lee', 'grace.lee@email.com', '+81-3-1234', 'Tokyo', 'Japan'), + ('Henry', 'Wilson', 'henry.wilson@email.com', '+61-2-5678', 'Sydney', 'Australia'); + +-- Categories +INSERT INTO categories (category_name, description) VALUES + ('Electronics', 'Electronic devices and accessories'), + ('Books', 'Physical and digital books'), + ('Clothing', 'Apparel and fashion items'), + ('Home & Garden', 'Home improvement and garden supplies'), + ('Sports & Outdoors', 'Sports equipment and outdoor gear'), + ('Toys & Games', 'Toys, games, and entertainment'), + ('Health & Beauty', 'Health products and beauty supplies'); + +-- Products +INSERT INTO products (product_name, category_id, price, stock_quantity, description) VALUES + ('Wireless Bluetooth Headphones', 1, 79.99, 150, 'High-quality wireless headphones with noise cancellation'), + ('Laptop Stand', 1, 49.99, 200, 'Ergonomic aluminum laptop stand'), + ('USB-C Cable 6ft', 1, 12.99, 500, 'Fast charging USB-C cable'), + ('The Great Gatsby', 2, 14.99, 100, 'Classic American novel by F. Scott Fitzgerald'), + ('Clean Code', 2, 39.99, 75, 'A Handbook of Agile Software Craftsmanship'), + ('Mens Cotton T-Shirt', 3, 19.99, 300, 'Comfortable 100% cotton t-shirt'), + ('Womens Running Shoes', 3, 89.99, 120, 'Lightweight running shoes with arch support'), + ('Yoga Mat', 5, 24.99, 180, 'Non-slip exercise yoga mat'), + ('Dumbbell Set', 5, 99.99, 50, '20lb adjustable dumbbell set'), + ('LED Desk Lamp', 4, 34.99, 90, 'Adjustable brightness LED desk lamp'), + ('Indoor Plant Pot', 4, 15.99, 250, 'Ceramic plant pot with drainage'), + ('Board Game - Strategy', 6, 44.99, 60, 'Family-friendly strategy board game'), + ('Vitamin D Supplement', 7, 18.99, 200, '1000 IU vitamin D3 supplements'), + ('Face Moisturizer', 7, 29.99, 150, 'Hydrating face moisturizer with SPF'), + ('Smart Watch', 1, 199.99, 80, 'Fitness tracking smart watch'); + +-- Orders +INSERT INTO orders (customer_id, order_date, total_amount, status, shipping_address) VALUES + (1, '2024-12-01 10:30:00', 92.98, 'delivered', '123 Main St, New York, NY 10001'), + (1, '2024-12-15 14:20:00', 49.99, 'shipped', '123 Main St, New York, NY 10001'), + (2, '2024-12-03 09:15:00', 134.97, 'delivered', '456 Oak Ave, Los Angeles, CA 90001'), + (3, '2024-12-05 16:45:00', 79.99, 'delivered', '789 Pine Rd, Chicago, IL 60601'), + (4, '2024-12-08 11:00:00', 54.98, 'delivered', '10 Downing St, London, UK'), + (5, '2024-12-10 13:30:00', 199.99, 'shipped', '20 Unter den Linden, Berlin, Germany'), + (2, '2024-12-12 15:00:00', 89.99, 'processing', '456 Oak Ave, Los Angeles, CA 90001'), + (6, '2024-12-14 10:45:00', 44.99, 'pending', '30 Champs Elysees, Paris, France'), + (7, '2024-12-16 12:20:00', 124.98, 'processing', '40 Gran Via, Madrid, Spain'), + (8, '2024-12-18 14:00:00', 149.98, 'pending', '50 Ocean Dr, Miami, FL 33139'); + +-- Order Items +INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES + -- Order 1 + (1, 1, 1, 79.99), + (1, 3, 1, 12.99), + -- Order 2 + (2, 2, 1, 49.99), + -- Order 3 + (3, 7, 1, 89.99), + (3, 4, 1, 14.99), + (3, 6, 2, 19.99), + -- Order 4 + (4, 1, 1, 79.99), + -- Order 5 + (5, 4, 1, 14.99), + (5, 5, 1, 39.99), + -- Order 6 + (6, 15, 1, 199.99), + -- Order 7 + (7, 7, 1, 89.99), + -- Order 8 + (8, 12, 1, 44.99), + -- Order 9 + (9, 8, 1, 24.99), + (9, 9, 1, 99.99), + -- Order 10 + (10, 10, 1, 34.99), + (10, 11, 2, 15.99), + (10, 1, 1, 79.99); + +-- Create some indexes for better query performance +CREATE INDEX idx_products_category ON products(category_id); +CREATE INDEX idx_orders_customer ON orders(customer_id); +CREATE INDEX idx_orders_status ON orders(status); +CREATE INDEX idx_order_items_order ON order_items(order_id); +CREATE INDEX idx_order_items_product ON order_items(product_id); + +-- Create a view for order summaries +CREATE VIEW order_summary AS +SELECT + o.order_id, + c.first_name || ' ' || c.last_name AS customer_name, + c.email, + o.order_date, + o.status, + o.total_amount, + COUNT(oi.order_item_id) AS total_items +FROM orders o +JOIN customers c ON o.customer_id = c.customer_id +LEFT JOIN order_items oi ON o.order_id = oi.order_id +GROUP BY o.order_id, c.first_name, c.last_name, c.email, o.order_date, o.status, o.total_amount +ORDER BY o.order_date DESC; + +-- Grant necessary permissions +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO postgres; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO postgres; + +-- Display summary +DO $$ +BEGIN + RAISE NOTICE '==========================================='; + RAISE NOTICE 'Sample Database Initialized Successfully!'; + RAISE NOTICE '==========================================='; + RAISE NOTICE 'Tables created:'; + RAISE NOTICE ' - customers (% rows)', (SELECT COUNT(*) FROM customers); + RAISE NOTICE ' - categories (% rows)', (SELECT COUNT(*) FROM categories); + RAISE NOTICE ' - products (% rows)', (SELECT COUNT(*) FROM products); + RAISE NOTICE ' - orders (% rows)', (SELECT COUNT(*) FROM orders); + RAISE NOTICE ' - order_items (% rows)', (SELECT COUNT(*) FROM order_items); + RAISE NOTICE '==========================================='; +END $$;