awslabs.postgres-mcp-server 1.0.3__py3-none-any.whl → 1.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- awslabs/postgres_mcp_server/__init__.py +1 -1
- awslabs/postgres_mcp_server/connection/__init__.py +18 -0
- awslabs/postgres_mcp_server/connection/abstract_db_connection.py +68 -0
- awslabs/postgres_mcp_server/connection/db_connection_singleton.py +117 -0
- awslabs/postgres_mcp_server/connection/psycopg_pool_connection.py +287 -0
- awslabs/postgres_mcp_server/connection/rds_api_connection.py +157 -0
- awslabs/postgres_mcp_server/server.py +105 -211
- {awslabs_postgres_mcp_server-1.0.3.dist-info → awslabs_postgres_mcp_server-1.0.4.dist-info}/METADATA +73 -1
- awslabs_postgres_mcp_server-1.0.4.dist-info/RECORD +15 -0
- awslabs_postgres_mcp_server-1.0.3.dist-info/RECORD +0 -10
- {awslabs_postgres_mcp_server-1.0.3.dist-info → awslabs_postgres_mcp_server-1.0.4.dist-info}/WHEEL +0 -0
- {awslabs_postgres_mcp_server-1.0.3.dist-info → awslabs_postgres_mcp_server-1.0.4.dist-info}/entry_points.txt +0 -0
- {awslabs_postgres_mcp_server-1.0.3.dist-info → awslabs_postgres_mcp_server-1.0.4.dist-info}/licenses/LICENSE +0 -0
- {awslabs_postgres_mcp_server-1.0.3.dist-info → awslabs_postgres_mcp_server-1.0.4.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""aws.postgres-mcp-server.connection"""
|
|
16
|
+
|
|
17
|
+
from awslabs.postgres_mcp_server.connection.db_connection_singleton import DBConnectionSingleton
|
|
18
|
+
from awslabs.postgres_mcp_server.connection.abstract_db_connection import AbstractDBConnection
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Abstract database connection interface for postgres MCP Server."""
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AbstractDBConnection(ABC):
|
|
22
|
+
"""Abstract base class for database connections."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, readonly: bool):
|
|
25
|
+
"""Initialize the database connection.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
readonly: Whether the connection should be read-only
|
|
29
|
+
"""
|
|
30
|
+
self._readonly = readonly
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def readonly_query(self) -> bool:
|
|
34
|
+
"""Get whether this connection is read-only.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
bool: True if the connection is read-only, False otherwise
|
|
38
|
+
"""
|
|
39
|
+
return self._readonly
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def execute_query(
|
|
43
|
+
self, sql: str, parameters: Optional[List[Dict[str, Any]]] = None
|
|
44
|
+
) -> Dict[str, Any]:
|
|
45
|
+
"""Execute a SQL query.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
sql: The SQL query to execute
|
|
49
|
+
parameters: Optional parameters for the query
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Dict containing query results with column metadata and records
|
|
53
|
+
"""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def close(self) -> None:
|
|
58
|
+
"""Close the database connection."""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
async def check_connection_health(self) -> bool:
|
|
63
|
+
"""Check if the database connection is healthy.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
bool: True if the connection is healthy, False otherwise
|
|
67
|
+
"""
|
|
68
|
+
pass
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Database connection singleton for postgres MCP Server."""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from awslabs.postgres_mcp_server.connection.rds_api_connection import RDSDataAPIConnection
|
|
19
|
+
from loguru import logger
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DBConnectionSingleton:
|
|
23
|
+
"""Manages a single RDS Data API connection instance across the application."""
|
|
24
|
+
|
|
25
|
+
_instance = None
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
resource_arn: str,
|
|
30
|
+
secret_arn: str,
|
|
31
|
+
database: str,
|
|
32
|
+
region: str,
|
|
33
|
+
readonly: bool = True,
|
|
34
|
+
is_test: bool = False,
|
|
35
|
+
):
|
|
36
|
+
"""Initialize a new DB connection singleton for RDS Data API.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
resource_arn: The ARN of the RDS cluster
|
|
40
|
+
secret_arn: The ARN of the secret containing credentials
|
|
41
|
+
database: The name of the database to connect to
|
|
42
|
+
region: The AWS region where the RDS instance is located
|
|
43
|
+
readonly: Whether the connection should be read-only (default: True)
|
|
44
|
+
is_test: Whether this is a test connection (default: False)
|
|
45
|
+
"""
|
|
46
|
+
if not all([resource_arn, secret_arn, database, region]):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
'Missing required connection parameters for RDS Data API. '
|
|
49
|
+
'Please provide resource_arn, secret_arn, database, and region.'
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
self._db_connection = RDSDataAPIConnection(
|
|
53
|
+
cluster_arn=resource_arn,
|
|
54
|
+
secret_arn=secret_arn,
|
|
55
|
+
database=database,
|
|
56
|
+
region=region,
|
|
57
|
+
readonly=readonly,
|
|
58
|
+
is_test=is_test,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def initialize(
|
|
63
|
+
cls,
|
|
64
|
+
resource_arn: str,
|
|
65
|
+
secret_arn: str,
|
|
66
|
+
database: str,
|
|
67
|
+
region: str,
|
|
68
|
+
readonly: bool = True,
|
|
69
|
+
is_test: bool = False,
|
|
70
|
+
):
|
|
71
|
+
"""Initialize the singleton instance if it doesn't exist.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
resource_arn: The ARN of the RDS cluster
|
|
75
|
+
secret_arn: The ARN of the secret containing credentials
|
|
76
|
+
database: The name of the database to connect to
|
|
77
|
+
region: The AWS region where the RDS instance is located
|
|
78
|
+
readonly: Whether the connection should be read-only (default: True)
|
|
79
|
+
is_test: Whether this is a test connection (default: False)
|
|
80
|
+
"""
|
|
81
|
+
if cls._instance is None:
|
|
82
|
+
cls._instance = cls(
|
|
83
|
+
resource_arn=resource_arn,
|
|
84
|
+
secret_arn=secret_arn,
|
|
85
|
+
database=database,
|
|
86
|
+
region=region,
|
|
87
|
+
readonly=readonly,
|
|
88
|
+
is_test=is_test,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def get(cls):
|
|
93
|
+
"""Get the singleton instance."""
|
|
94
|
+
if cls._instance is None:
|
|
95
|
+
raise RuntimeError('DBConnectionSingleton is not initialized.')
|
|
96
|
+
return cls._instance
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def db_connection(self):
|
|
100
|
+
"""Get the database connection."""
|
|
101
|
+
return self._db_connection
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def cleanup(cls):
|
|
105
|
+
"""Clean up resources when shutting down."""
|
|
106
|
+
if cls._instance and cls._instance._db_connection:
|
|
107
|
+
# Handle calling async close method from sync context
|
|
108
|
+
try:
|
|
109
|
+
loop = asyncio.get_event_loop()
|
|
110
|
+
if loop.is_running():
|
|
111
|
+
# If we're in an async context, create a task
|
|
112
|
+
asyncio.create_task(cls._instance._db_connection.close())
|
|
113
|
+
else:
|
|
114
|
+
# If we're in a sync context, run the coroutine to completion
|
|
115
|
+
loop.run_until_complete(cls._instance._db_connection.close())
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error(f'Error during connection cleanup: {str(e)}')
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Psycopg connector for postgres MCP Server.
|
|
16
|
+
|
|
17
|
+
This connector provides direct connection to PostgreSQL databases using psycopg.
|
|
18
|
+
It supports both Aurora PostgreSQL and RDS PostgreSQL instances via direct connection
|
|
19
|
+
parameters (host, port, database, user, password) or via AWS Secrets Manager.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import boto3
|
|
23
|
+
import json
|
|
24
|
+
from awslabs.postgres_mcp_server.connection.abstract_db_connection import AbstractDBConnection
|
|
25
|
+
from loguru import logger
|
|
26
|
+
from psycopg_pool import AsyncConnectionPool
|
|
27
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PsycopgPoolConnection(AbstractDBConnection):
|
|
31
|
+
"""Class that wraps DB connection using psycopg connection pool.
|
|
32
|
+
|
|
33
|
+
This class can connect directly to any PostgreSQL database, including:
|
|
34
|
+
- Aurora PostgreSQL (using the cluster endpoint)
|
|
35
|
+
- RDS PostgreSQL (using the instance endpoint)
|
|
36
|
+
- Self-hosted PostgreSQL
|
|
37
|
+
|
|
38
|
+
It uses AWS Secrets Manager (secret_arn and region) for authentication.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
host: str,
|
|
44
|
+
port: int,
|
|
45
|
+
database: str,
|
|
46
|
+
readonly: bool,
|
|
47
|
+
secret_arn: str,
|
|
48
|
+
region: str,
|
|
49
|
+
min_size: int = 1,
|
|
50
|
+
max_size: int = 10,
|
|
51
|
+
is_test: bool = False,
|
|
52
|
+
):
|
|
53
|
+
"""Initialize a new DB connection pool.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
host: Database host (Aurora cluster endpoint or RDS instance endpoint)
|
|
57
|
+
port: Database port
|
|
58
|
+
database: Database name
|
|
59
|
+
readonly: Whether connections should be read-only
|
|
60
|
+
secret_arn: ARN of the secret containing credentials
|
|
61
|
+
region: AWS region for Secrets Manager
|
|
62
|
+
min_size: Minimum number of connections in the pool
|
|
63
|
+
max_size: Maximum number of connections in the pool
|
|
64
|
+
is_test: Whether this is a test connection
|
|
65
|
+
"""
|
|
66
|
+
super().__init__(readonly)
|
|
67
|
+
self.host = host
|
|
68
|
+
self.port = port
|
|
69
|
+
self.database = database
|
|
70
|
+
self.min_size = min_size
|
|
71
|
+
self.max_size = max_size
|
|
72
|
+
self.pool: Optional['AsyncConnectionPool[Any]'] = None
|
|
73
|
+
|
|
74
|
+
# Get credentials from Secrets Manager
|
|
75
|
+
logger.info(f'Retrieving credentials from Secrets Manager: {secret_arn}')
|
|
76
|
+
self.user, self.password = self._get_credentials_from_secret(secret_arn, region, is_test)
|
|
77
|
+
logger.info(f'Successfully retrieved credentials for user: {self.user}')
|
|
78
|
+
|
|
79
|
+
# Store connection info
|
|
80
|
+
if not is_test:
|
|
81
|
+
self.conninfo = f'host={host} port={port} dbname={database} user={self.user} password={self.password}'
|
|
82
|
+
logger.info('Connection parameters stored')
|
|
83
|
+
|
|
84
|
+
async def initialize_pool(self):
|
|
85
|
+
"""Initialize the connection pool."""
|
|
86
|
+
if self.pool is None:
|
|
87
|
+
logger.info(
|
|
88
|
+
f'Initializing connection pool with min_size={self.min_size}, max_size={self.max_size}'
|
|
89
|
+
)
|
|
90
|
+
self.pool = AsyncConnectionPool(
|
|
91
|
+
self.conninfo, min_size=self.min_size, max_size=self.max_size, open=True
|
|
92
|
+
)
|
|
93
|
+
logger.info('Connection pool initialized successfully')
|
|
94
|
+
|
|
95
|
+
# Set read-only mode if needed
|
|
96
|
+
if self.readonly_query:
|
|
97
|
+
await self._set_all_connections_readonly()
|
|
98
|
+
|
|
99
|
+
async def _get_connection(self):
|
|
100
|
+
"""Get a database connection from the pool."""
|
|
101
|
+
if self.pool is None:
|
|
102
|
+
await self.initialize_pool()
|
|
103
|
+
|
|
104
|
+
if self.pool is None:
|
|
105
|
+
raise ValueError('Failed to initialize connection pool')
|
|
106
|
+
|
|
107
|
+
return self.pool.connection(timeout=15.0)
|
|
108
|
+
|
|
109
|
+
async def _set_all_connections_readonly(self):
|
|
110
|
+
"""Set all connections in the pool to read-only mode."""
|
|
111
|
+
if self.pool is None:
|
|
112
|
+
logger.warning('Connection pool is not initialized, cannot set read-only mode')
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
async with self.pool.connection(timeout=15.0) as conn:
|
|
117
|
+
await conn.execute(
|
|
118
|
+
'ALTER ROLE CURRENT_USER SET default_transaction_read_only = on'
|
|
119
|
+
) # type: ignore
|
|
120
|
+
logger.info('Successfully set connection to read-only mode')
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.warning(f'Failed to set connections to read-only mode: {str(e)}')
|
|
123
|
+
logger.warning('Continuing without setting read-only mode')
|
|
124
|
+
|
|
125
|
+
async def execute_query(
|
|
126
|
+
self, sql: str, parameters: Optional[List[Dict[str, Any]]] = None
|
|
127
|
+
) -> Dict[str, Any]:
|
|
128
|
+
"""Execute a SQL query using async connection."""
|
|
129
|
+
try:
|
|
130
|
+
async with await self._get_connection() as conn:
|
|
131
|
+
async with conn.transaction():
|
|
132
|
+
if self.readonly_query:
|
|
133
|
+
await conn.execute('SET TRANSACTION READ ONLY') # type: ignore
|
|
134
|
+
|
|
135
|
+
# Create a cursor for better control
|
|
136
|
+
async with conn.cursor() as cursor:
|
|
137
|
+
# Execute the query
|
|
138
|
+
if parameters:
|
|
139
|
+
params = self._convert_parameters(parameters)
|
|
140
|
+
await cursor.execute(sql, params)
|
|
141
|
+
else:
|
|
142
|
+
await cursor.execute(sql)
|
|
143
|
+
|
|
144
|
+
# Check if there are results to fetch by examining the cursor's description
|
|
145
|
+
if cursor.description:
|
|
146
|
+
# Get column names
|
|
147
|
+
columns = [desc[0] for desc in cursor.description]
|
|
148
|
+
|
|
149
|
+
# Fetch all rows
|
|
150
|
+
rows = await cursor.fetchall()
|
|
151
|
+
|
|
152
|
+
# Structure the response to match the interface contract required by server.py
|
|
153
|
+
column_metadata = [{'name': col} for col in columns]
|
|
154
|
+
records = []
|
|
155
|
+
|
|
156
|
+
# Convert each row to the expected format
|
|
157
|
+
for row in rows:
|
|
158
|
+
record = []
|
|
159
|
+
for value in row:
|
|
160
|
+
if value is None:
|
|
161
|
+
record.append({'isNull': True})
|
|
162
|
+
elif isinstance(value, str):
|
|
163
|
+
record.append({'stringValue': value})
|
|
164
|
+
elif isinstance(value, int):
|
|
165
|
+
record.append({'longValue': value})
|
|
166
|
+
elif isinstance(value, float):
|
|
167
|
+
record.append({'doubleValue': value})
|
|
168
|
+
elif isinstance(value, bool):
|
|
169
|
+
record.append({'booleanValue': value})
|
|
170
|
+
elif isinstance(value, bytes):
|
|
171
|
+
record.append({'blobValue': value})
|
|
172
|
+
else:
|
|
173
|
+
# Convert other types to string
|
|
174
|
+
record.append({'stringValue': str(value)})
|
|
175
|
+
records.append(record)
|
|
176
|
+
|
|
177
|
+
return {'columnMetadata': column_metadata, 'records': records}
|
|
178
|
+
else:
|
|
179
|
+
# No results (e.g., for INSERT, UPDATE, etc.)
|
|
180
|
+
return {'columnMetadata': [], 'records': []}
|
|
181
|
+
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f'Database connection error: {str(e)}')
|
|
184
|
+
raise e
|
|
185
|
+
|
|
186
|
+
def _convert_parameters(self, parameters: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
187
|
+
"""Transform structured parameter format to psycopg's native parameter format."""
|
|
188
|
+
result = {}
|
|
189
|
+
for param in parameters:
|
|
190
|
+
name = param.get('name')
|
|
191
|
+
value = param.get('value', {})
|
|
192
|
+
|
|
193
|
+
# Extract the value based on its type
|
|
194
|
+
if 'stringValue' in value:
|
|
195
|
+
result[name] = value['stringValue']
|
|
196
|
+
elif 'longValue' in value:
|
|
197
|
+
result[name] = value['longValue']
|
|
198
|
+
elif 'doubleValue' in value:
|
|
199
|
+
result[name] = value['doubleValue']
|
|
200
|
+
elif 'booleanValue' in value:
|
|
201
|
+
result[name] = value['booleanValue']
|
|
202
|
+
elif 'blobValue' in value:
|
|
203
|
+
result[name] = value['blobValue']
|
|
204
|
+
elif 'isNull' in value and value['isNull']:
|
|
205
|
+
result[name] = None
|
|
206
|
+
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
def _get_credentials_from_secret(
|
|
210
|
+
self, secret_arn: str, region: str, is_test: bool = False
|
|
211
|
+
) -> Tuple[str, str]:
|
|
212
|
+
"""Get database credentials from AWS Secrets Manager."""
|
|
213
|
+
if is_test:
|
|
214
|
+
return 'test_user', 'test_password'
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
# Create a Secrets Manager client
|
|
218
|
+
logger.info(f'Creating Secrets Manager client in region {region}')
|
|
219
|
+
session = boto3.Session()
|
|
220
|
+
client = session.client(service_name='secretsmanager', region_name=region)
|
|
221
|
+
|
|
222
|
+
# Get the secret value
|
|
223
|
+
logger.info(f'Retrieving secret value for {secret_arn}')
|
|
224
|
+
get_secret_value_response = client.get_secret_value(SecretId=secret_arn)
|
|
225
|
+
logger.info('Successfully retrieved secret value')
|
|
226
|
+
|
|
227
|
+
# Parse the secret string
|
|
228
|
+
if 'SecretString' in get_secret_value_response:
|
|
229
|
+
secret = json.loads(get_secret_value_response['SecretString'])
|
|
230
|
+
logger.info(f'Secret keys: {", ".join(secret.keys())}')
|
|
231
|
+
|
|
232
|
+
# Extract username and password
|
|
233
|
+
username = secret.get('username') or secret.get('user') or secret.get('Username')
|
|
234
|
+
password = secret.get('password') or secret.get('Password')
|
|
235
|
+
|
|
236
|
+
if not username:
|
|
237
|
+
logger.error(
|
|
238
|
+
f'Username not found in secret. Available keys: {", ".join(secret.keys())}'
|
|
239
|
+
)
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f'Secret does not contain username. Available keys: {", ".join(secret.keys())}'
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if not password:
|
|
245
|
+
logger.error('Password not found in secret')
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f'Secret does not contain password. Available keys: {", ".join(secret.keys())}'
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
logger.info(f'Successfully extracted credentials for user: {username}')
|
|
251
|
+
return username, password
|
|
252
|
+
else:
|
|
253
|
+
logger.error('Secret does not contain a SecretString')
|
|
254
|
+
raise ValueError('Secret does not contain a SecretString')
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error(f'Error retrieving secret: {str(e)}')
|
|
257
|
+
raise ValueError(f'Failed to retrieve credentials from Secrets Manager: {str(e)}')
|
|
258
|
+
|
|
259
|
+
async def close(self) -> None:
|
|
260
|
+
"""Close all connections in the pool."""
|
|
261
|
+
if self.pool is not None:
|
|
262
|
+
logger.info('Closing connection pool')
|
|
263
|
+
await self.pool.close()
|
|
264
|
+
self.pool = None
|
|
265
|
+
logger.info('Connection pool closed successfully')
|
|
266
|
+
|
|
267
|
+
async def check_connection_health(self) -> bool:
|
|
268
|
+
"""Check if the connection is healthy."""
|
|
269
|
+
try:
|
|
270
|
+
result = await self.execute_query('SELECT 1')
|
|
271
|
+
return len(result.get('records', [])) > 0
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.error(f'Connection health check failed: {str(e)}')
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
def get_pool_stats(self) -> Dict[str, int]:
|
|
277
|
+
"""Get current connection pool statistics."""
|
|
278
|
+
if not hasattr(self, 'pool') or self.pool is None:
|
|
279
|
+
return {'size': 0, 'min_size': self.min_size, 'max_size': self.max_size, 'idle': 0}
|
|
280
|
+
|
|
281
|
+
# Access pool attributes safely
|
|
282
|
+
size = getattr(self.pool, 'size', 0)
|
|
283
|
+
min_size = getattr(self.pool, 'min_size', self.min_size)
|
|
284
|
+
max_size = getattr(self.pool, 'max_size', self.max_size)
|
|
285
|
+
idle = getattr(self.pool, 'idle', 0)
|
|
286
|
+
|
|
287
|
+
return {'size': size, 'min_size': min_size, 'max_size': max_size, 'idle': idle}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""RDS Data API connector for postgres MCP Server."""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import boto3
|
|
19
|
+
from awslabs.postgres_mcp_server.connection.abstract_db_connection import AbstractDBConnection
|
|
20
|
+
from loguru import logger
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RDSDataAPIConnection(AbstractDBConnection):
|
|
25
|
+
"""Class that wraps DB connection client by RDS API."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
cluster_arn: str,
|
|
30
|
+
secret_arn: str,
|
|
31
|
+
database: str,
|
|
32
|
+
region: str,
|
|
33
|
+
readonly: bool,
|
|
34
|
+
is_test: bool = False,
|
|
35
|
+
):
|
|
36
|
+
"""Initialize a new DB connection.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
cluster_arn: The ARN of the RDS cluster
|
|
40
|
+
secret_arn: The ARN of the secret containing credentials
|
|
41
|
+
database: The name of the database to connect to
|
|
42
|
+
region: The AWS region where the RDS instance is located
|
|
43
|
+
readonly: Whether the connection should be read-only
|
|
44
|
+
is_test: Whether this is a test connection
|
|
45
|
+
"""
|
|
46
|
+
super().__init__(readonly)
|
|
47
|
+
self.cluster_arn = cluster_arn
|
|
48
|
+
self.secret_arn = secret_arn
|
|
49
|
+
self.database = database
|
|
50
|
+
if not is_test:
|
|
51
|
+
self.data_client = boto3.client('rds-data', region_name=region)
|
|
52
|
+
|
|
53
|
+
async def execute_query(
|
|
54
|
+
self, sql: str, parameters: Optional[List[Dict[str, Any]]] = None
|
|
55
|
+
) -> Dict[str, Any]:
|
|
56
|
+
"""Execute a SQL query using RDS Data API.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
sql: The SQL query to execute
|
|
60
|
+
parameters: Optional parameters for the query
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Dict containing query results with column metadata and records
|
|
64
|
+
"""
|
|
65
|
+
if self.readonly_query:
|
|
66
|
+
return await asyncio.to_thread(self._execute_readonly_query, sql, parameters)
|
|
67
|
+
else:
|
|
68
|
+
execute_params = {
|
|
69
|
+
'resourceArn': self.cluster_arn,
|
|
70
|
+
'secretArn': self.secret_arn,
|
|
71
|
+
'database': self.database,
|
|
72
|
+
'sql': sql,
|
|
73
|
+
'includeResultMetadata': True,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if parameters:
|
|
77
|
+
execute_params['parameters'] = parameters
|
|
78
|
+
|
|
79
|
+
return await asyncio.to_thread(self.data_client.execute_statement, **execute_params)
|
|
80
|
+
|
|
81
|
+
def _execute_readonly_query(
|
|
82
|
+
self, query: str, parameters: Optional[List[Dict[str, Any]]] = None
|
|
83
|
+
) -> Dict[str, Any]:
|
|
84
|
+
"""Execute a query under readonly transaction.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
query: query to run
|
|
88
|
+
parameters: parameters
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Dict containing query results with column metadata and records
|
|
92
|
+
"""
|
|
93
|
+
tx_id = ''
|
|
94
|
+
try:
|
|
95
|
+
# Begin read-only transaction
|
|
96
|
+
tx = self.data_client.begin_transaction(
|
|
97
|
+
resourceArn=self.cluster_arn,
|
|
98
|
+
secretArn=self.secret_arn,
|
|
99
|
+
database=self.database,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
tx_id = tx['transactionId']
|
|
103
|
+
|
|
104
|
+
self.data_client.execute_statement(
|
|
105
|
+
resourceArn=self.cluster_arn,
|
|
106
|
+
secretArn=self.secret_arn,
|
|
107
|
+
database=self.database,
|
|
108
|
+
sql='SET TRANSACTION READ ONLY',
|
|
109
|
+
transactionId=tx_id,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
execute_params = {
|
|
113
|
+
'resourceArn': self.cluster_arn,
|
|
114
|
+
'secretArn': self.secret_arn,
|
|
115
|
+
'database': self.database,
|
|
116
|
+
'sql': query,
|
|
117
|
+
'includeResultMetadata': True,
|
|
118
|
+
'transactionId': tx_id,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if parameters is not None:
|
|
122
|
+
execute_params['parameters'] = parameters
|
|
123
|
+
|
|
124
|
+
result = self.data_client.execute_statement(**execute_params)
|
|
125
|
+
|
|
126
|
+
self.data_client.commit_transaction(
|
|
127
|
+
resourceArn=self.cluster_arn,
|
|
128
|
+
secretArn=self.secret_arn,
|
|
129
|
+
transactionId=tx_id,
|
|
130
|
+
)
|
|
131
|
+
return result
|
|
132
|
+
except Exception as e:
|
|
133
|
+
if tx_id:
|
|
134
|
+
self.data_client.rollback_transaction(
|
|
135
|
+
resourceArn=self.cluster_arn,
|
|
136
|
+
secretArn=self.secret_arn,
|
|
137
|
+
transactionId=tx_id,
|
|
138
|
+
)
|
|
139
|
+
raise e
|
|
140
|
+
|
|
141
|
+
async def close(self) -> None:
|
|
142
|
+
"""Close the database connection asynchronously."""
|
|
143
|
+
# RDS Data API doesn't maintain persistent connections
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
async def check_connection_health(self) -> bool:
|
|
147
|
+
"""Check if the RDS Data API connection is healthy.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
bool: True if the connection is healthy, False otherwise
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
result = await self.execute_query('SELECT 1')
|
|
154
|
+
return len(result.get('records', [])) > 0
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.error(f'RDS Data API connection health check failed: {str(e)}')
|
|
157
|
+
return False
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
import argparse
|
|
18
18
|
import asyncio
|
|
19
|
-
import boto3
|
|
20
19
|
import sys
|
|
20
|
+
from awslabs.postgres_mcp_server.connection import DBConnectionSingleton
|
|
21
|
+
from awslabs.postgres_mcp_server.connection.psycopg_pool_connection import PsycopgPoolConnection
|
|
21
22
|
from awslabs.postgres_mcp_server.mutable_sql_detector import (
|
|
22
23
|
check_sql_injection_risk,
|
|
23
24
|
detect_mutating_keywords,
|
|
@@ -49,104 +50,6 @@ class DummyCtx:
|
|
|
49
50
|
pass
|
|
50
51
|
|
|
51
52
|
|
|
52
|
-
class DBConnection:
|
|
53
|
-
"""Class that wraps DB connection client by RDS API."""
|
|
54
|
-
|
|
55
|
-
def __init__(self, cluster_arn, secret_arn, database, region, readonly, is_test=False):
|
|
56
|
-
"""Initialize a new DB connection.
|
|
57
|
-
|
|
58
|
-
Args:
|
|
59
|
-
cluster_arn: The ARN of the RDS cluster
|
|
60
|
-
secret_arn: The ARN of the secret containing credentials
|
|
61
|
-
database: The name of the database to connect to
|
|
62
|
-
region: The AWS region where the RDS instance is located
|
|
63
|
-
readonly: Whether the connection should be read-only
|
|
64
|
-
is_test: Whether this is a test connection
|
|
65
|
-
"""
|
|
66
|
-
self.cluster_arn = cluster_arn
|
|
67
|
-
self.secret_arn = secret_arn
|
|
68
|
-
self.database = database
|
|
69
|
-
self.readonly = readonly
|
|
70
|
-
if not is_test:
|
|
71
|
-
self.data_client = boto3.client('rds-data', region_name=region)
|
|
72
|
-
|
|
73
|
-
@property
|
|
74
|
-
def readonly_query(self):
|
|
75
|
-
"""Get whether this connection is read-only.
|
|
76
|
-
|
|
77
|
-
Returns:
|
|
78
|
-
bool: True if the connection is read-only, False otherwise
|
|
79
|
-
"""
|
|
80
|
-
return self.readonly
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
class DBConnectionSingleton:
|
|
84
|
-
"""Manages a single DBConnection instance across the application.
|
|
85
|
-
|
|
86
|
-
This singleton ensures that only one DBConnection is created and reused.
|
|
87
|
-
"""
|
|
88
|
-
|
|
89
|
-
_instance = None
|
|
90
|
-
|
|
91
|
-
def __init__(self, resource_arn, secret_arn, database, region, readonly, is_test=False):
|
|
92
|
-
"""Initialize a new DB connection singleton.
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
resource_arn: The ARN of the RDS resource
|
|
96
|
-
secret_arn: The ARN of the secret containing credentials
|
|
97
|
-
database: The name of the database to connect to
|
|
98
|
-
region: The AWS region where the RDS instance is located
|
|
99
|
-
readonly: Whether the connection should be read-only
|
|
100
|
-
is_test: Whether this is a test connection
|
|
101
|
-
"""
|
|
102
|
-
if not all([resource_arn, secret_arn, database, region]):
|
|
103
|
-
raise ValueError(
|
|
104
|
-
'Missing required connection parameters. '
|
|
105
|
-
'Please provide resource_arn, secret_arn, database, and region.'
|
|
106
|
-
)
|
|
107
|
-
self._db_connection = DBConnection(
|
|
108
|
-
resource_arn, secret_arn, database, region, readonly, is_test
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
@classmethod
|
|
112
|
-
def initialize(cls, resource_arn, secret_arn, database, region, readonly, is_test=False):
|
|
113
|
-
"""Initialize the singleton instance if it doesn't exist.
|
|
114
|
-
|
|
115
|
-
Args:
|
|
116
|
-
resource_arn: The ARN of the RDS resource
|
|
117
|
-
secret_arn: The ARN of the secret containing credentials
|
|
118
|
-
database: The name of the database to connect to
|
|
119
|
-
region: The AWS region where the RDS instance is located
|
|
120
|
-
readonly: Whether the connection should be read-only
|
|
121
|
-
is_test: Whether this is a test connection
|
|
122
|
-
"""
|
|
123
|
-
if cls._instance is None:
|
|
124
|
-
cls._instance = cls(resource_arn, secret_arn, database, region, readonly, is_test)
|
|
125
|
-
|
|
126
|
-
@classmethod
|
|
127
|
-
def get(cls):
|
|
128
|
-
"""Get the singleton instance.
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
DBConnectionSingleton: The singleton instance
|
|
132
|
-
|
|
133
|
-
Raises:
|
|
134
|
-
RuntimeError: If the singleton has not been initialized
|
|
135
|
-
"""
|
|
136
|
-
if cls._instance is None:
|
|
137
|
-
raise RuntimeError('DBConnectionSingleton is not initialized.')
|
|
138
|
-
return cls._instance
|
|
139
|
-
|
|
140
|
-
@property
|
|
141
|
-
def db_connection(self):
|
|
142
|
-
"""Get the database connection.
|
|
143
|
-
|
|
144
|
-
Returns:
|
|
145
|
-
DBConnection: The database connection instance
|
|
146
|
-
"""
|
|
147
|
-
return self._db_connection
|
|
148
|
-
|
|
149
|
-
|
|
150
53
|
def extract_cell(cell: dict):
|
|
151
54
|
"""Extracts the scalar or array value from a single cell."""
|
|
152
55
|
if cell.get('isNull'):
|
|
@@ -177,14 +80,14 @@ def parse_execute_response(response: dict) -> list[dict]:
|
|
|
177
80
|
|
|
178
81
|
|
|
179
82
|
mcp = FastMCP(
|
|
180
|
-
'
|
|
83
|
+
'pg-mcp MCP server. This is the starting point for all solutions created',
|
|
181
84
|
dependencies=[
|
|
182
85
|
'loguru',
|
|
183
86
|
],
|
|
184
87
|
)
|
|
185
88
|
|
|
186
89
|
|
|
187
|
-
@mcp.tool(name='run_query', description='Run a SQL query
|
|
90
|
+
@mcp.tool(name='run_query', description='Run a SQL query against PostgreSQL')
|
|
188
91
|
async def run_query(
|
|
189
92
|
sql: Annotated[str, Field(description='The SQL query to run')],
|
|
190
93
|
ctx: Context,
|
|
@@ -193,12 +96,12 @@ async def run_query(
|
|
|
193
96
|
Optional[List[Dict[str, Any]]], Field(description='Parameters for the SQL query')
|
|
194
97
|
] = None,
|
|
195
98
|
) -> list[dict]: # type: ignore
|
|
196
|
-
"""Run a SQL query
|
|
99
|
+
"""Run a SQL query against PostgreSQL.
|
|
197
100
|
|
|
198
101
|
Args:
|
|
199
102
|
sql: The sql statement to run
|
|
200
103
|
ctx: MCP context for logging and state management
|
|
201
|
-
db_connection: DB connection object passed by unit test. It should be None if
|
|
104
|
+
db_connection: DB connection object passed by unit test. It should be None if called by MCP server.
|
|
202
105
|
query_parameters: Parameters for the SQL query
|
|
203
106
|
|
|
204
107
|
Returns:
|
|
@@ -209,7 +112,17 @@ async def run_query(
|
|
|
209
112
|
global write_query_prohibited_key
|
|
210
113
|
|
|
211
114
|
if db_connection is None:
|
|
212
|
-
|
|
115
|
+
try:
|
|
116
|
+
# Try to get the connection from the singleton
|
|
117
|
+
db_connection = DBConnectionSingleton.get().db_connection
|
|
118
|
+
except RuntimeError:
|
|
119
|
+
# If the singleton is not initialized, this might be a direct connection
|
|
120
|
+
logger.error('No database connection available')
|
|
121
|
+
await ctx.error('No database connection available')
|
|
122
|
+
return [{'error': 'No database connection available'}]
|
|
123
|
+
|
|
124
|
+
if db_connection is None:
|
|
125
|
+
raise AssertionError('db_connection should never be None')
|
|
213
126
|
|
|
214
127
|
if db_connection.readonly_query:
|
|
215
128
|
matches = detect_mutating_keywords(sql)
|
|
@@ -233,27 +146,10 @@ async def run_query(
|
|
|
233
146
|
try:
|
|
234
147
|
logger.info(f'run_query: readonly:{db_connection.readonly_query}, SQL:{sql}')
|
|
235
148
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
execute_readonly_query, db_connection, sql, query_parameters
|
|
239
|
-
)
|
|
240
|
-
else:
|
|
241
|
-
execute_params = {
|
|
242
|
-
'resourceArn': db_connection.cluster_arn,
|
|
243
|
-
'secretArn': db_connection.secret_arn,
|
|
244
|
-
'database': db_connection.database,
|
|
245
|
-
'sql': sql,
|
|
246
|
-
'includeResultMetadata': True,
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if query_parameters:
|
|
250
|
-
execute_params['parameters'] = query_parameters
|
|
251
|
-
|
|
252
|
-
response = await asyncio.to_thread(
|
|
253
|
-
db_connection.data_client.execute_statement, **execute_params
|
|
254
|
-
)
|
|
149
|
+
# Execute the query using the abstract connection interface
|
|
150
|
+
response = await db_connection.execute_query(sql, query_parameters)
|
|
255
151
|
|
|
256
|
-
logger.success('run_query successfully executed query:{}'
|
|
152
|
+
logger.success(f'run_query successfully executed query:{sql}')
|
|
257
153
|
return parse_execute_response(response)
|
|
258
154
|
except ClientError as e:
|
|
259
155
|
logger.exception(client_error_code_key)
|
|
@@ -270,7 +166,7 @@ async def run_query(
|
|
|
270
166
|
|
|
271
167
|
@mcp.tool(
|
|
272
168
|
name='get_table_schema',
|
|
273
|
-
description='Fetch table columns and comments from Postgres
|
|
169
|
+
description='Fetch table columns and comments from Postgres',
|
|
274
170
|
)
|
|
275
171
|
async def get_table_schema(
|
|
276
172
|
table_name: Annotated[str, Field(description='name of the table')], ctx: Context
|
|
@@ -305,68 +201,6 @@ async def get_table_schema(
|
|
|
305
201
|
return await run_query(sql=sql, ctx=ctx, query_parameters=params)
|
|
306
202
|
|
|
307
203
|
|
|
308
|
-
def execute_readonly_query(
|
|
309
|
-
db_connection: DBConnection, query: str, parameters: Optional[List[Dict[str, Any]]] = None
|
|
310
|
-
) -> dict:
|
|
311
|
-
"""Execute a query under readonly transaction.
|
|
312
|
-
|
|
313
|
-
Args:
|
|
314
|
-
db_connection: connection object
|
|
315
|
-
query: query to run
|
|
316
|
-
parameters: parameters
|
|
317
|
-
|
|
318
|
-
Returns:
|
|
319
|
-
List of dictionary that contains query response rows
|
|
320
|
-
"""
|
|
321
|
-
tx_id = ''
|
|
322
|
-
try:
|
|
323
|
-
# Begin read-only transaction
|
|
324
|
-
tx = db_connection.data_client.begin_transaction(
|
|
325
|
-
resourceArn=db_connection.cluster_arn,
|
|
326
|
-
secretArn=db_connection.secret_arn,
|
|
327
|
-
database=db_connection.database,
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
tx_id = tx['transactionId']
|
|
331
|
-
|
|
332
|
-
db_connection.data_client.execute_statement(
|
|
333
|
-
resourceArn=db_connection.cluster_arn,
|
|
334
|
-
secretArn=db_connection.secret_arn,
|
|
335
|
-
database=db_connection.database,
|
|
336
|
-
sql='SET TRANSACTION READ ONLY',
|
|
337
|
-
transactionId=tx_id,
|
|
338
|
-
)
|
|
339
|
-
|
|
340
|
-
execute_params = {
|
|
341
|
-
'resourceArn': db_connection.cluster_arn,
|
|
342
|
-
'secretArn': db_connection.secret_arn,
|
|
343
|
-
'database': db_connection.database,
|
|
344
|
-
'sql': query,
|
|
345
|
-
'includeResultMetadata': True,
|
|
346
|
-
'transactionId': tx_id,
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if parameters is not None:
|
|
350
|
-
execute_params['parameters'] = parameters
|
|
351
|
-
|
|
352
|
-
result = db_connection.data_client.execute_statement(**execute_params)
|
|
353
|
-
|
|
354
|
-
db_connection.data_client.commit_transaction(
|
|
355
|
-
resourceArn=db_connection.cluster_arn,
|
|
356
|
-
secretArn=db_connection.secret_arn,
|
|
357
|
-
transactionId=tx_id,
|
|
358
|
-
)
|
|
359
|
-
return result
|
|
360
|
-
except Exception as e:
|
|
361
|
-
if tx_id:
|
|
362
|
-
db_connection.data_client.rollback_transaction(
|
|
363
|
-
resourceArn=db_connection.cluster_arn,
|
|
364
|
-
secretArn=db_connection.secret_arn,
|
|
365
|
-
transactionId=tx_id,
|
|
366
|
-
)
|
|
367
|
-
raise e
|
|
368
|
-
|
|
369
|
-
|
|
370
204
|
def main():
|
|
371
205
|
"""Main entry point for the MCP server application."""
|
|
372
206
|
global client_error_code_key
|
|
@@ -375,51 +209,111 @@ def main():
|
|
|
375
209
|
parser = argparse.ArgumentParser(
|
|
376
210
|
description='An AWS Labs Model Context Protocol (MCP) server for postgres'
|
|
377
211
|
)
|
|
378
|
-
|
|
212
|
+
|
|
213
|
+
# Connection method 1: RDS Data API
|
|
214
|
+
parser.add_argument('--resource_arn', help='ARN of the RDS cluster (for RDS Data API)')
|
|
215
|
+
|
|
216
|
+
# Connection method 2: Psycopg Direct Connection
|
|
217
|
+
parser.add_argument('--hostname', help='Database hostname (for direct PostgreSQL connection)')
|
|
218
|
+
parser.add_argument('--port', type=int, default=5432, help='Database port (default: 5432)')
|
|
219
|
+
|
|
220
|
+
# Common parameters
|
|
379
221
|
parser.add_argument(
|
|
380
222
|
'--secret_arn',
|
|
381
223
|
required=True,
|
|
382
224
|
help='ARN of the Secrets Manager secret for database credentials',
|
|
383
225
|
)
|
|
384
226
|
parser.add_argument('--database', required=True, help='Database name')
|
|
385
|
-
parser.add_argument(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
parser.add_argument(
|
|
389
|
-
'--readonly', required=True, help='Enforce NL to SQL to only allow readonly sql statement'
|
|
390
|
-
)
|
|
227
|
+
parser.add_argument('--region', required=True, help='AWS region')
|
|
228
|
+
parser.add_argument('--readonly', required=True, help='Enforce readonly SQL statements')
|
|
229
|
+
|
|
391
230
|
args = parser.parse_args()
|
|
392
231
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
args.readonly,
|
|
400
|
-
)
|
|
232
|
+
# Validate connection parameters
|
|
233
|
+
if not args.resource_arn and not args.hostname:
|
|
234
|
+
parser.error(
|
|
235
|
+
'Either --resource_arn (for RDS Data API) or '
|
|
236
|
+
'--hostname (for direct PostgreSQL) must be provided'
|
|
237
|
+
)
|
|
401
238
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
239
|
+
if args.resource_arn and args.hostname:
|
|
240
|
+
parser.error(
|
|
241
|
+
'Cannot specify both --resource_arn and --hostname. Choose one connection method.'
|
|
405
242
|
)
|
|
406
|
-
|
|
407
|
-
|
|
243
|
+
|
|
244
|
+
# Convert args to dict for easier handling
|
|
245
|
+
connection_params = vars(args)
|
|
246
|
+
|
|
247
|
+
# Convert readonly string to boolean
|
|
248
|
+
connection_params['readonly'] = args.readonly.lower() == 'true'
|
|
249
|
+
|
|
250
|
+
# Log connection information
|
|
251
|
+
connection_target = args.resource_arn if args.resource_arn else f'{args.hostname}:{args.port}'
|
|
252
|
+
|
|
253
|
+
if args.resource_arn:
|
|
254
|
+
logger.info(
|
|
255
|
+
f'Postgres MCP init with RDS Data API: CONNECTION_TARGET:{connection_target}, SECRET_ARN:{args.secret_arn}, REGION:{args.region}, DATABASE:{args.database}, READONLY:{args.readonly}'
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
logger.info(
|
|
259
|
+
f'Postgres MCP init with psycopg: CONNECTION_TARGET:{connection_target}, PORT:{args.port}, DATABASE:{args.database}, READONLY:{args.readonly}'
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Create the appropriate database connection based on the provided parameters
|
|
263
|
+
db_connection = None
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
if args.resource_arn:
|
|
267
|
+
# Use RDS Data API with singleton pattern
|
|
268
|
+
try:
|
|
269
|
+
# Initialize the RDS Data API connection singleton
|
|
270
|
+
DBConnectionSingleton.initialize(
|
|
271
|
+
resource_arn=args.resource_arn,
|
|
272
|
+
secret_arn=args.secret_arn,
|
|
273
|
+
database=args.database,
|
|
274
|
+
region=args.region,
|
|
275
|
+
readonly=connection_params['readonly'],
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Get the connection from the singleton
|
|
279
|
+
db_connection = DBConnectionSingleton.get().db_connection
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.exception(f'Failed to create RDS Data API connection: {str(e)}')
|
|
282
|
+
sys.exit(1)
|
|
283
|
+
|
|
284
|
+
else:
|
|
285
|
+
# Use Direct PostgreSQL connection using psycopg connection pool
|
|
286
|
+
try:
|
|
287
|
+
# Create a direct PostgreSQL connection pool
|
|
288
|
+
db_connection = PsycopgPoolConnection(
|
|
289
|
+
host=args.hostname,
|
|
290
|
+
port=args.port,
|
|
291
|
+
database=args.database,
|
|
292
|
+
readonly=connection_params['readonly'],
|
|
293
|
+
secret_arn=args.secret_arn,
|
|
294
|
+
region=args.region,
|
|
295
|
+
)
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.exception(f'Failed to create PostgreSQL connection: {str(e)}')
|
|
298
|
+
sys.exit(1)
|
|
299
|
+
|
|
300
|
+
except BotoCoreError as e:
|
|
301
|
+
logger.exception(f'Failed to create database connection: {str(e)}')
|
|
408
302
|
sys.exit(1)
|
|
409
303
|
|
|
410
|
-
# Test
|
|
304
|
+
# Test database connection
|
|
411
305
|
ctx = DummyCtx()
|
|
412
|
-
response = asyncio.run(run_query('SELECT 1', ctx))
|
|
306
|
+
response = asyncio.run(run_query('SELECT 1', ctx, db_connection))
|
|
413
307
|
if (
|
|
414
308
|
isinstance(response, list)
|
|
415
309
|
and len(response) == 1
|
|
416
310
|
and isinstance(response[0], dict)
|
|
417
311
|
and 'error' in response[0]
|
|
418
312
|
):
|
|
419
|
-
logger.error('Failed to validate
|
|
313
|
+
logger.error('Failed to validate database connection to Postgres. Exit the MCP server')
|
|
420
314
|
sys.exit(1)
|
|
421
315
|
|
|
422
|
-
logger.success('Successfully validated
|
|
316
|
+
logger.success('Successfully validated database connection to Postgres')
|
|
423
317
|
|
|
424
318
|
logger.info('Starting Postgres MCP server')
|
|
425
319
|
mcp.run()
|
{awslabs_postgres_mcp_server-1.0.3.dist-info → awslabs_postgres_mcp_server-1.0.4.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: awslabs.postgres-mcp-server
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: An AWS Labs Model Context Protocol (MCP) server for postgres
|
|
5
5
|
Project-URL: homepage, https://awslabs.github.io/mcp/
|
|
6
6
|
Project-URL: docs, https://awslabs.github.io/mcp/servers/postgres-mcp-server/
|
|
@@ -25,6 +25,7 @@ Requires-Dist: boto3>=1.38.5
|
|
|
25
25
|
Requires-Dist: botocore>=1.38.5
|
|
26
26
|
Requires-Dist: loguru>=0.7.0
|
|
27
27
|
Requires-Dist: mcp[cli]>=1.11.0
|
|
28
|
+
Requires-Dist: psycopg[pool]>=3.1.12
|
|
28
29
|
Requires-Dist: pydantic>=2.10.6
|
|
29
30
|
Description-Content-Type: text/markdown
|
|
30
31
|
|
|
@@ -58,6 +59,8 @@ An AWS Labs Model Context Protocol (MCP) server for Aurora Postgres
|
|
|
58
59
|
|
|
59
60
|
Configure the MCP server in your MCP client configuration (e.g., for Amazon Q Developer CLI, edit `~/.aws/amazonq/mcp.json`):
|
|
60
61
|
|
|
62
|
+
### Option 1: Using RDS Data API Connection (for Aurora Postgres)
|
|
63
|
+
|
|
61
64
|
```json
|
|
62
65
|
{
|
|
63
66
|
"mcpServers": {
|
|
@@ -83,6 +86,35 @@ Configure the MCP server in your MCP client configuration (e.g., for Amazon Q De
|
|
|
83
86
|
}
|
|
84
87
|
```
|
|
85
88
|
|
|
89
|
+
### Option 2: Using Direct PostgreSQL(psycopg) Connection (for Aurora Postgres and RDS Postgres)
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"mcpServers": {
|
|
94
|
+
"awslabs.postgres-mcp-server": {
|
|
95
|
+
"command": "uvx",
|
|
96
|
+
"args": [
|
|
97
|
+
"awslabs.postgres-mcp-server@latest",
|
|
98
|
+
"--hostname", "[your data]",
|
|
99
|
+
"--secret_arn", "[your data]",
|
|
100
|
+
"--database", "[your data]",
|
|
101
|
+
"--region", "[your data]",
|
|
102
|
+
"--readonly", "True"
|
|
103
|
+
],
|
|
104
|
+
"env": {
|
|
105
|
+
"AWS_PROFILE": "your-aws-profile",
|
|
106
|
+
"AWS_REGION": "us-east-1",
|
|
107
|
+
"FASTMCP_LOG_LEVEL": "ERROR"
|
|
108
|
+
},
|
|
109
|
+
"disabled": false,
|
|
110
|
+
"autoApprove": []
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Note: The `--port` parameter is optional and defaults to 5432 (the standard PostgreSQL port). You only need to specify it if your PostgreSQL instance uses a non-standard port.
|
|
117
|
+
|
|
86
118
|
### Build and install docker image locally on the same host of your LLM client
|
|
87
119
|
|
|
88
120
|
1. 'git clone https://github.com/awslabs/mcp.git'
|
|
@@ -91,6 +123,8 @@ Configure the MCP server in your MCP client configuration (e.g., for Amazon Q De
|
|
|
91
123
|
|
|
92
124
|
### Add or update your LLM client's config with following:
|
|
93
125
|
|
|
126
|
+
#### Option 1: Using RDS Data API Connection (for Aurora Postgres)
|
|
127
|
+
|
|
94
128
|
```json
|
|
95
129
|
{
|
|
96
130
|
"mcpServers": {
|
|
@@ -115,8 +149,46 @@ Configure the MCP server in your MCP client configuration (e.g., for Amazon Q De
|
|
|
115
149
|
}
|
|
116
150
|
```
|
|
117
151
|
|
|
152
|
+
#### Option 2: Using Direct PostgreSQL (psycopg) Connection (for Aurora Postgres and RDS Postgres)
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
{
|
|
156
|
+
"mcpServers": {
|
|
157
|
+
"awslabs.postgres-mcp-server": {
|
|
158
|
+
"command": "docker",
|
|
159
|
+
"args": [
|
|
160
|
+
"run",
|
|
161
|
+
"-i",
|
|
162
|
+
"--rm",
|
|
163
|
+
"-e", "AWS_ACCESS_KEY_ID=[your data]",
|
|
164
|
+
"-e", "AWS_SECRET_ACCESS_KEY=[your data]",
|
|
165
|
+
"-e", "AWS_REGION=[your data]",
|
|
166
|
+
"awslabs/postgres-mcp-server:latest",
|
|
167
|
+
"--hostname", "[your data]",
|
|
168
|
+
"--secret_arn", "[your data]",
|
|
169
|
+
"--database", "[your data]",
|
|
170
|
+
"--region", "[your data]",
|
|
171
|
+
"--readonly", "True"
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Note: The `--port` parameter is optional and defaults to 5432 (the standard PostgreSQL port). You only need to specify it if your PostgreSQL instance uses a non-standard port.
|
|
179
|
+
|
|
118
180
|
NOTE: By default, only read-only queries are allowed and it is controlled by --readonly parameter above. Set it to False if you also want to allow writable DML or DDL.
|
|
119
181
|
|
|
182
|
+
## Connection Methods
|
|
183
|
+
|
|
184
|
+
This MCP server supports two connection methods:
|
|
185
|
+
|
|
186
|
+
1. **RDS Data API Connection** (using `--resource_arn`): Uses the AWS RDS Data API to connect to Aurora PostgreSQL. This method requires that your Aurora cluster has the Data API enabled.
|
|
187
|
+
|
|
188
|
+
2. **Direct PostgreSQL Connection** (using `--hostname`): Uses psycopg to connect directly to any PostgreSQL database, including Aurora PostgreSQL, RDS PostgreSQL, or self-hosted PostgreSQL instances. This method provides better performance for frequent queries but requires direct network access to the database.
|
|
189
|
+
|
|
190
|
+
Choose the connection method that best fits your environment and requirements.
|
|
191
|
+
|
|
120
192
|
### AWS Authentication
|
|
121
193
|
|
|
122
194
|
The MCP server uses the AWS profile specified in the `AWS_PROFILE` environment variable. If not provided, it defaults to the "default" profile in your AWS configuration file.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
awslabs/__init__.py,sha256=WuqxdDgUZylWNmVoPKiK7qGsTB_G4UmuXIrJ-VBwDew,731
|
|
2
|
+
awslabs/postgres_mcp_server/__init__.py,sha256=Hmj3ZFqpAA8HzLCrR48AtfJthOFX7HH6_iNzE_E_2Q4,673
|
|
3
|
+
awslabs/postgres_mcp_server/mutable_sql_detector.py,sha256=bivLNglukgfi0IWcQSfDcwxFae4Q8CSvb--yWW617l0,2717
|
|
4
|
+
awslabs/postgres_mcp_server/server.py,sha256=wenII8rWhhUVVo-m2C2cPWVwS1S_Us3LUKmKQrr9ebw,11621
|
|
5
|
+
awslabs/postgres_mcp_server/connection/__init__.py,sha256=9j_gZeLcWoXu_Fny1PkC3RsnDMOHr9M3OM9EWvlGR7o,850
|
|
6
|
+
awslabs/postgres_mcp_server/connection/abstract_db_connection.py,sha256=BGdyRoF1fgjLoFSiWg-XdS0KJ1UKuNgV03gGcRXOG8U,2080
|
|
7
|
+
awslabs/postgres_mcp_server/connection/db_connection_singleton.py,sha256=Fwm6uTwB-bhprx-UgiOttDFtczeMUfW5M79wGL0x1Go,4279
|
|
8
|
+
awslabs/postgres_mcp_server/connection/psycopg_pool_connection.py,sha256=qHbRR8-P__v5_-_Q-AQdZVD_nTHj-2Y1p-j1RnKRmrs,12593
|
|
9
|
+
awslabs/postgres_mcp_server/connection/rds_api_connection.py,sha256=1G4qfik5Nu-KbAxRLFNshQ5DJxFnJQI6eqLJPXp-Qn4,5419
|
|
10
|
+
awslabs_postgres_mcp_server-1.0.4.dist-info/METADATA,sha256=aiAGagfCfoskIxv-rVx9mPqTt7rVbqhAf_Oq_DJUlcg,8410
|
|
11
|
+
awslabs_postgres_mcp_server-1.0.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
12
|
+
awslabs_postgres_mcp_server-1.0.4.dist-info/entry_points.txt,sha256=qHCxq_MTANxTB8-mA7Wl6H71qWEMwmG-I6_koII4AXY,88
|
|
13
|
+
awslabs_postgres_mcp_server-1.0.4.dist-info/licenses/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
|
|
14
|
+
awslabs_postgres_mcp_server-1.0.4.dist-info/licenses/NOTICE,sha256=qMIIe3h7I1Y4-CKejn50wbSKXEZLWhYHdKaRwKdXN9M,95
|
|
15
|
+
awslabs_postgres_mcp_server-1.0.4.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
awslabs/__init__.py,sha256=WuqxdDgUZylWNmVoPKiK7qGsTB_G4UmuXIrJ-VBwDew,731
|
|
2
|
-
awslabs/postgres_mcp_server/__init__.py,sha256=OpiWDc6HV8EoZVGVKb6ebzcIm7TUbZeALyFkLdm7YWg,673
|
|
3
|
-
awslabs/postgres_mcp_server/mutable_sql_detector.py,sha256=bivLNglukgfi0IWcQSfDcwxFae4Q8CSvb--yWW617l0,2717
|
|
4
|
-
awslabs/postgres_mcp_server/server.py,sha256=Hw8qPrjlqoKoPatP2485RN_ntXfwb09OIPYOAKyRtrk,14500
|
|
5
|
-
awslabs_postgres_mcp_server-1.0.3.dist-info/METADATA,sha256=N7XIGheT6KY3uZguuG-1VpdJchn37MMX5Trc6w7uI90,5952
|
|
6
|
-
awslabs_postgres_mcp_server-1.0.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
-
awslabs_postgres_mcp_server-1.0.3.dist-info/entry_points.txt,sha256=qHCxq_MTANxTB8-mA7Wl6H71qWEMwmG-I6_koII4AXY,88
|
|
8
|
-
awslabs_postgres_mcp_server-1.0.3.dist-info/licenses/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
|
|
9
|
-
awslabs_postgres_mcp_server-1.0.3.dist-info/licenses/NOTICE,sha256=qMIIe3h7I1Y4-CKejn50wbSKXEZLWhYHdKaRwKdXN9M,95
|
|
10
|
-
awslabs_postgres_mcp_server-1.0.3.dist-info/RECORD,,
|
{awslabs_postgres_mcp_server-1.0.3.dist-info → awslabs_postgres_mcp_server-1.0.4.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|