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.
@@ -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
- def remove_comments(sql: str) -> str:
62
- """Remove SQL comments from the input string.
63
-
64
- Args:
65
- sql: The SQL string to process
66
-
67
- Returns:
68
- The SQL string with all comments removed
69
- """
70
- sql = re.sub(r'--.*?$', '', sql, flags=re.MULTILINE)
71
- sql = re.sub(r'/\*.*?\*/', '', sql, flags=re.DOTALL)
72
- return sql
73
-
74
-
75
- def remove_strings(sql: str) -> str:
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
- cleaned_sql = remove_comments(sql_text)
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(parameters: list[dict] | None) -> list[dict]:
97
- """Check for potential SQL injection risks in query parameters.
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
- parameters: List of parameter dictionaries containing name and value pairs
77
+ sql: query string
101
78
 
102
79
  Returns:
103
- List of dictionaries containing detected security issues
80
+ dictionaries containing detected security issue
104
81
  """
105
82
  issues = []
106
-
107
- if parameters is not None:
108
- for param in parameters:
109
- value = next(iter(param['value'].values()))
110
- for pattern in SUSPICIOUS_PATTERNS:
111
- if re.search(pattern, str(value)):
112
- issues.append(
113
- {
114
- 'type': 'parameter',
115
- 'parameter_name': param['name'],
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
- raise RuntimeError(f'MCP Tool Error: {message}')
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
- if query_parameters is not None:
218
- issues = check_sql_injection_risk(query_parameters)
219
- if issues:
220
- logger.info(
221
- f'query is rejected because it contains risky SQL pattern, SQL query: {sql}, reasons: {issues}'
222
- )
223
- await ctx.error(
224
- str({'message': 'Query parameter contains suspicious pattern', 'details': issues})
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
- response = await asyncio.to_thread(
243
- db_connection.data_client.execute_statement, **execute_params
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.error(f'{client_error_code_key}: {e.response["Error"]["Message"]}')
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': write_query_prohibited_key}]
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': write_query_prohibited_key}]
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 = f"""
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 = '{table_name}'::regclass
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
- """ # nosec B608: injection risk is handled inside run_query
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 as e:
335
- logger.error(
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
- try:
343
- asyncio.run(run_query('SELECT 1', ctx))
344
- except Exception as e:
345
- logger.error(
346
- f'Failed to validate RDS API db connection to Postgres. Exit the MCP server. error: {e}'
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
- # Run server with appropriate transport
353
- if args.sse:
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__':
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awslabs.postgres-mcp-server
3
- Version: 0.0.4
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,,