awslabs.postgres-mcp-server 0.0.4__py3-none-any.whl → 1.0.0__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/mutable_sql_detector.py +31 -60
- awslabs/postgres_mcp_server/server.py +118 -54
- {awslabs_postgres_mcp_server-0.0.4.dist-info → awslabs_postgres_mcp_server-1.0.0.dist-info}/METADATA +1 -1
- awslabs_postgres_mcp_server-1.0.0.dist-info/RECORD +10 -0
- awslabs_postgres_mcp_server-0.0.4.dist-info/RECORD +0 -10
- {awslabs_postgres_mcp_server-0.0.4.dist-info → awslabs_postgres_mcp_server-1.0.0.dist-info}/WHEEL +0 -0
- {awslabs_postgres_mcp_server-0.0.4.dist-info → awslabs_postgres_mcp_server-1.0.0.dist-info}/entry_points.txt +0 -0
- {awslabs_postgres_mcp_server-0.0.4.dist-info → awslabs_postgres_mcp_server-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {awslabs_postgres_mcp_server-0.0.4.dist-info → awslabs_postgres_mcp_server-1.0.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -41,82 +41,53 @@ MUTATING_KEYWORDS = {
|
|
|
41
41
|
'ANALYZE',
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
SUSPICIOUS_PATTERNS = [
|
|
45
|
-
r"(?i)'.*?--",
|
|
46
|
-
r"(?i)'.*?or\s+1=1",
|
|
47
|
-
r'(?i)\bunion\b.*\bselect\b',
|
|
48
|
-
r'(?i)\bdrop\b',
|
|
49
|
-
r'(?i)\btruncate\b',
|
|
50
|
-
r'(?i)\bgrant\b|\brevoke\b',
|
|
51
|
-
r'(?i);',
|
|
52
|
-
r"(?i)or\s+['\"]?\d+=\d+",
|
|
53
|
-
]
|
|
54
|
-
|
|
55
44
|
# Compile regex pattern
|
|
56
45
|
MUTATING_PATTERN = re.compile(
|
|
57
46
|
r'(?i)\b(' + '|'.join(re.escape(k) for k in MUTATING_KEYWORDS) + r')\b'
|
|
58
47
|
)
|
|
59
48
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"""Remove string literals from the SQL query.
|
|
77
|
-
|
|
78
|
-
Args:
|
|
79
|
-
sql: The SQL string to process
|
|
80
|
-
|
|
81
|
-
Returns:
|
|
82
|
-
The SQL string with all string literals removed
|
|
83
|
-
"""
|
|
84
|
-
# Remove single-quoted and double-quoted string literals
|
|
85
|
-
return re.sub(r"('([^']|'')*')|(\"([^\"]|\"\")*\")", '', sql)
|
|
49
|
+
SUSPICIOUS_PATTERNS = [
|
|
50
|
+
r'--.*$', # single-line comment
|
|
51
|
+
r'/\*.*?\*/', # multi-line comment
|
|
52
|
+
r"(?i)'.*?--", # comment injection
|
|
53
|
+
r'(?i)\bor\b\s+\d+\s*=\s*\d+', # numeric tautology e.g. OR 1=1
|
|
54
|
+
r"(?i)\bor\b\s*'[^']+'\s*=\s*'[^']+'", # string tautology e.g. OR '1'='1'
|
|
55
|
+
r'(?i)\bunion\b.*\bselect\b', # UNION SELECT
|
|
56
|
+
r'(?i)\bdrop\b', # DROP statement
|
|
57
|
+
r'(?i)\btruncate\b', # TRUNCATE
|
|
58
|
+
r'(?i)\bgrant\b|\brevoke\b', # GRANT or REVOKE
|
|
59
|
+
r'(?i);', # stacked queries
|
|
60
|
+
r'(?i)\bsleep\s*\(', # delay-based probes
|
|
61
|
+
r'(?i)\bpg_sleep\s*\(',
|
|
62
|
+
r'(?i)\bload_file\s*\(',
|
|
63
|
+
r'(?i)\binto\s+outfile\b',
|
|
64
|
+
]
|
|
86
65
|
|
|
87
66
|
|
|
88
67
|
def detect_mutating_keywords(sql_text: str) -> list[str]:
|
|
89
68
|
"""Return a list of mutating keywords found in the SQL (excluding comments)."""
|
|
90
|
-
|
|
91
|
-
cleaned_sql = remove_strings(cleaned_sql)
|
|
92
|
-
matches = MUTATING_PATTERN.findall(cleaned_sql)
|
|
69
|
+
matches = MUTATING_PATTERN.findall(sql_text)
|
|
93
70
|
return list({m.upper() for m in matches}) # Deduplicated and normalized to uppercase
|
|
94
71
|
|
|
95
72
|
|
|
96
|
-
def check_sql_injection_risk(
|
|
97
|
-
"""Check for potential SQL injection risks in query
|
|
73
|
+
def check_sql_injection_risk(sql: str) -> list[dict]:
|
|
74
|
+
"""Check for potential SQL injection risks in sql query.
|
|
98
75
|
|
|
99
76
|
Args:
|
|
100
|
-
|
|
77
|
+
sql: query string
|
|
101
78
|
|
|
102
79
|
Returns:
|
|
103
|
-
|
|
80
|
+
dictionaries containing detected security issue
|
|
104
81
|
"""
|
|
105
82
|
issues = []
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
'message': f'Suspicious pattern in value: {value}',
|
|
117
|
-
'severity': 'high',
|
|
118
|
-
}
|
|
119
|
-
)
|
|
120
|
-
break
|
|
121
|
-
|
|
83
|
+
for pattern in SUSPICIOUS_PATTERNS:
|
|
84
|
+
if re.search(pattern, sql):
|
|
85
|
+
issues.append(
|
|
86
|
+
{
|
|
87
|
+
'type': 'sql',
|
|
88
|
+
'message': f'Suspicious pattern in query: {sql}',
|
|
89
|
+
'severity': 'high',
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
break
|
|
122
93
|
return issues
|
|
@@ -29,18 +29,21 @@ from typing import Annotated, Any, Dict, List, Optional
|
|
|
29
29
|
client_error_code_key = 'run_query ClientError code'
|
|
30
30
|
unexpected_error_key = 'run_query unexpected error'
|
|
31
31
|
write_query_prohibited_key = 'Your MCP tool only allows readonly query. If you want to write, change the MCP configuration per README.md'
|
|
32
|
+
query_comment_prohibited_key = 'The comment in query is prohibited because of injection risk'
|
|
33
|
+
query_injection_risk_key = 'Your query contains risky injection patterns'
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
class DummyCtx:
|
|
35
37
|
"""A dummy context class for error handling in MCP tools."""
|
|
36
38
|
|
|
37
|
-
def error(self, message):
|
|
39
|
+
async def error(self, message):
|
|
38
40
|
"""Raise a runtime error with the given message.
|
|
39
41
|
|
|
40
42
|
Args:
|
|
41
43
|
message: The error message to include in the runtime error
|
|
42
44
|
"""
|
|
43
|
-
|
|
45
|
+
# Do nothing
|
|
46
|
+
pass
|
|
44
47
|
|
|
45
48
|
|
|
46
49
|
class DBConnection:
|
|
@@ -214,48 +217,52 @@ async def run_query(
|
|
|
214
217
|
await ctx.error(write_query_prohibited_key)
|
|
215
218
|
return [{'error': write_query_prohibited_key}]
|
|
216
219
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return [{'error': write_query_prohibited_key}]
|
|
220
|
+
issues = check_sql_injection_risk(sql)
|
|
221
|
+
if issues:
|
|
222
|
+
logger.info(
|
|
223
|
+
f'query is rejected because it contains risky SQL pattern, SQL query: {sql}, reasons: {issues}'
|
|
224
|
+
)
|
|
225
|
+
await ctx.error(
|
|
226
|
+
str({'message': 'Query parameter contains suspicious pattern', 'details': issues})
|
|
227
|
+
)
|
|
228
|
+
return [{'error': query_injection_risk_key}]
|
|
227
229
|
|
|
228
230
|
try:
|
|
229
|
-
logger.info(f'run_query: {sql}')
|
|
230
|
-
|
|
231
|
-
execute_params = {
|
|
232
|
-
'resourceArn': db_connection.cluster_arn,
|
|
233
|
-
'secretArn': db_connection.secret_arn,
|
|
234
|
-
'database': db_connection.database,
|
|
235
|
-
'sql': sql,
|
|
236
|
-
'includeResultMetadata': True,
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if query_parameters:
|
|
240
|
-
execute_params['parameters'] = query_parameters
|
|
231
|
+
logger.info(f'run_query: readonly:{db_connection.readonly_query}, SQL:{sql}')
|
|
241
232
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
233
|
+
if db_connection.readonly_query:
|
|
234
|
+
response = await asyncio.to_thread(
|
|
235
|
+
execute_readonly_query, db_connection, sql, query_parameters
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
execute_params = {
|
|
239
|
+
'resourceArn': db_connection.cluster_arn,
|
|
240
|
+
'secretArn': db_connection.secret_arn,
|
|
241
|
+
'database': db_connection.database,
|
|
242
|
+
'sql': sql,
|
|
243
|
+
'includeResultMetadata': True,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if query_parameters:
|
|
247
|
+
execute_params['parameters'] = query_parameters
|
|
248
|
+
|
|
249
|
+
response = await asyncio.to_thread(
|
|
250
|
+
db_connection.data_client.execute_statement, **execute_params
|
|
251
|
+
)
|
|
245
252
|
|
|
246
253
|
logger.success('run_query successfully executed query:{}', sql)
|
|
247
254
|
return parse_execute_response(response)
|
|
248
255
|
except ClientError as e:
|
|
249
|
-
logger.
|
|
256
|
+
logger.exception(client_error_code_key)
|
|
250
257
|
await ctx.error(
|
|
251
258
|
str({'code': e.response['Error']['Code'], 'message': e.response['Error']['Message']})
|
|
252
259
|
)
|
|
253
|
-
return [{'error':
|
|
260
|
+
return [{'error': client_error_code_key}]
|
|
254
261
|
except Exception as e:
|
|
262
|
+
logger.exception(unexpected_error_key)
|
|
255
263
|
error_details = f'{type(e).__name__}: {str(e)}'
|
|
256
|
-
logger.error(f'{unexpected_error_key}: {error_details}')
|
|
257
264
|
await ctx.error(str({'message': error_details}))
|
|
258
|
-
return [{'error':
|
|
265
|
+
return [{'error': unexpected_error_key}]
|
|
259
266
|
|
|
260
267
|
|
|
261
268
|
@mcp.tool(
|
|
@@ -276,7 +283,7 @@ async def get_table_schema(
|
|
|
276
283
|
"""
|
|
277
284
|
logger.info(f'get_table_schema: {table_name}')
|
|
278
285
|
|
|
279
|
-
sql =
|
|
286
|
+
sql = """
|
|
280
287
|
SELECT
|
|
281
288
|
a.attname AS column_name,
|
|
282
289
|
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
|
|
@@ -284,13 +291,77 @@ async def get_table_schema(
|
|
|
284
291
|
FROM
|
|
285
292
|
pg_attribute a
|
|
286
293
|
WHERE
|
|
287
|
-
a.attrelid =
|
|
294
|
+
a.attrelid = :table_name::regclass
|
|
288
295
|
AND a.attnum > 0
|
|
289
296
|
AND NOT a.attisdropped
|
|
290
297
|
ORDER BY a.attnum
|
|
291
|
-
"""
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
params = [{'name': 'table_name', 'value': {'stringValue': table_name}}]
|
|
292
301
|
|
|
293
|
-
return await run_query(sql, ctx)
|
|
302
|
+
return await run_query(sql=sql, ctx=ctx, query_parameters=params)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def execute_readonly_query(
|
|
306
|
+
db_connection: DBConnection, query: str, parameters: Optional[List[Dict[str, Any]]] = None
|
|
307
|
+
) -> dict:
|
|
308
|
+
"""Execute a query under readonly transaction.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
db_connection: connection object
|
|
312
|
+
query: query to run
|
|
313
|
+
parameters: parameters
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
List of dictionary that contains query response rows
|
|
317
|
+
"""
|
|
318
|
+
tx_id = ''
|
|
319
|
+
try:
|
|
320
|
+
# Begin read-only transaction
|
|
321
|
+
tx = db_connection.data_client.begin_transaction(
|
|
322
|
+
resourceArn=db_connection.cluster_arn,
|
|
323
|
+
secretArn=db_connection.secret_arn,
|
|
324
|
+
database=db_connection.database,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
tx_id = tx['transactionId']
|
|
328
|
+
|
|
329
|
+
db_connection.data_client.execute_statement(
|
|
330
|
+
resourceArn=db_connection.cluster_arn,
|
|
331
|
+
secretArn=db_connection.secret_arn,
|
|
332
|
+
database=db_connection.database,
|
|
333
|
+
sql='SET TRANSACTION READ ONLY',
|
|
334
|
+
transactionId=tx_id,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
execute_params = {
|
|
338
|
+
'resourceArn': db_connection.cluster_arn,
|
|
339
|
+
'secretArn': db_connection.secret_arn,
|
|
340
|
+
'database': db_connection.database,
|
|
341
|
+
'sql': query,
|
|
342
|
+
'includeResultMetadata': True,
|
|
343
|
+
'transactionId': tx_id,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if parameters is not None:
|
|
347
|
+
execute_params['parameters'] = parameters
|
|
348
|
+
|
|
349
|
+
result = db_connection.data_client.execute_statement(**execute_params)
|
|
350
|
+
|
|
351
|
+
db_connection.data_client.commit_transaction(
|
|
352
|
+
resourceArn=db_connection.cluster_arn,
|
|
353
|
+
secretArn=db_connection.secret_arn,
|
|
354
|
+
transactionId=tx_id,
|
|
355
|
+
)
|
|
356
|
+
return result
|
|
357
|
+
except Exception as e:
|
|
358
|
+
if tx_id:
|
|
359
|
+
db_connection.data_client.rollback_transaction(
|
|
360
|
+
resourceArn=db_connection.cluster_arn,
|
|
361
|
+
secretArn=db_connection.secret_arn,
|
|
362
|
+
transactionId=tx_id,
|
|
363
|
+
)
|
|
364
|
+
raise e
|
|
294
365
|
|
|
295
366
|
|
|
296
367
|
def main():
|
|
@@ -301,8 +372,6 @@ def main():
|
|
|
301
372
|
parser = argparse.ArgumentParser(
|
|
302
373
|
description='An AWS Labs Model Context Protocol (MCP) server for postgres'
|
|
303
374
|
)
|
|
304
|
-
parser.add_argument('--sse', action='store_true', help='Use SSE transport')
|
|
305
|
-
parser.add_argument('--port', type=int, default=8888, help='Port to run the server on')
|
|
306
375
|
parser.add_argument('--resource_arn', required=True, help='ARN of the RDS cluster')
|
|
307
376
|
parser.add_argument(
|
|
308
377
|
'--secret_arn',
|
|
@@ -331,31 +400,26 @@ def main():
|
|
|
331
400
|
DBConnectionSingleton.initialize(
|
|
332
401
|
args.resource_arn, args.secret_arn, args.database, args.region, args.readonly
|
|
333
402
|
)
|
|
334
|
-
except BotoCoreError
|
|
335
|
-
logger.
|
|
336
|
-
f'Failed to RDS API client object for Postgres. Exit the MCP server. error: {str(e)}'
|
|
337
|
-
)
|
|
403
|
+
except BotoCoreError:
|
|
404
|
+
logger.exception('Failed to RDS API client object for Postgres. Exit the MCP server')
|
|
338
405
|
sys.exit(1)
|
|
339
406
|
|
|
340
407
|
# Test RDS API connection
|
|
341
408
|
ctx = DummyCtx()
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
409
|
+
response = asyncio.run(run_query('SELECT 1', ctx))
|
|
410
|
+
if (
|
|
411
|
+
isinstance(response, list)
|
|
412
|
+
and len(response) == 1
|
|
413
|
+
and isinstance(response[0], dict)
|
|
414
|
+
and 'error' in response[0]
|
|
415
|
+
):
|
|
416
|
+
logger.error('Failed to validate RDS API db connection to Postgres. Exit the MCP server')
|
|
348
417
|
sys.exit(1)
|
|
349
418
|
|
|
350
419
|
logger.success('Successfully validated RDS API db connection to Postgres')
|
|
351
420
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
mcp.settings.port = args.port
|
|
355
|
-
mcp.run(transport='sse')
|
|
356
|
-
else:
|
|
357
|
-
logger.info('Starting Postgres MCP server')
|
|
358
|
-
mcp.run()
|
|
421
|
+
logger.info('Starting Postgres MCP server')
|
|
422
|
+
mcp.run()
|
|
359
423
|
|
|
360
424
|
|
|
361
425
|
if __name__ == '__main__':
|
{awslabs_postgres_mcp_server-0.0.4.dist-info → awslabs_postgres_mcp_server-1.0.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: awslabs.postgres-mcp-server
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 1.0.0
|
|
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/
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
awslabs/__init__.py,sha256=47wJeKcStxEJwX7SVVV2pnAWYR8FxcaYoT3YTmZ5Plg,674
|
|
2
|
+
awslabs/postgres_mcp_server/__init__.py,sha256=J9QAlEAe6DiFJ9irk9cOaM7NINate24hfP1RFiywKww,616
|
|
3
|
+
awslabs/postgres_mcp_server/mutable_sql_detector.py,sha256=1Rywe9i7zkAT2JxG_QIc1nr1v6J49yUjfwVTvVpZe3A,2655
|
|
4
|
+
awslabs/postgres_mcp_server/server.py,sha256=frD6WP6Iixdzs8qOh-2vAXkbOxVNNp_TuQYxVVcQPJU,14440
|
|
5
|
+
awslabs_postgres_mcp_server-1.0.0.dist-info/METADATA,sha256=4sXbcBYaEGxKpxTf-WRuGcX3Sw1b2uJ-D9cKCt0z-w8,4897
|
|
6
|
+
awslabs_postgres_mcp_server-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
+
awslabs_postgres_mcp_server-1.0.0.dist-info/entry_points.txt,sha256=qHCxq_MTANxTB8-mA7Wl6H71qWEMwmG-I6_koII4AXY,88
|
|
8
|
+
awslabs_postgres_mcp_server-1.0.0.dist-info/licenses/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
|
|
9
|
+
awslabs_postgres_mcp_server-1.0.0.dist-info/licenses/NOTICE,sha256=qMIIe3h7I1Y4-CKejn50wbSKXEZLWhYHdKaRwKdXN9M,95
|
|
10
|
+
awslabs_postgres_mcp_server-1.0.0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
awslabs/__init__.py,sha256=47wJeKcStxEJwX7SVVV2pnAWYR8FxcaYoT3YTmZ5Plg,674
|
|
2
|
-
awslabs/postgres_mcp_server/__init__.py,sha256=J9QAlEAe6DiFJ9irk9cOaM7NINate24hfP1RFiywKww,616
|
|
3
|
-
awslabs/postgres_mcp_server/mutable_sql_detector.py,sha256=ciO8aCR06IZxzC6ui-brEqqou0YCO-iYX-ZFwNclQHw,3396
|
|
4
|
-
awslabs/postgres_mcp_server/server.py,sha256=WSKNaoxgcnq17Bcjiom1qhA6DGN_rqlq7yYAmiKXzmU,12432
|
|
5
|
-
awslabs_postgres_mcp_server-0.0.4.dist-info/METADATA,sha256=YwkdfUcb4EbxCn35HVPH-Wp0P2jSMLEBljWT7uNiv-U,4897
|
|
6
|
-
awslabs_postgres_mcp_server-0.0.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
-
awslabs_postgres_mcp_server-0.0.4.dist-info/entry_points.txt,sha256=qHCxq_MTANxTB8-mA7Wl6H71qWEMwmG-I6_koII4AXY,88
|
|
8
|
-
awslabs_postgres_mcp_server-0.0.4.dist-info/licenses/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
|
|
9
|
-
awslabs_postgres_mcp_server-0.0.4.dist-info/licenses/NOTICE,sha256=qMIIe3h7I1Y4-CKejn50wbSKXEZLWhYHdKaRwKdXN9M,95
|
|
10
|
-
awslabs_postgres_mcp_server-0.0.4.dist-info/RECORD,,
|
{awslabs_postgres_mcp_server-0.0.4.dist-info → awslabs_postgres_mcp_server-1.0.0.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|