awslabs.dynamodb-mcp-server 2.0.10__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/__init__.py +17 -0
- awslabs/dynamodb_mcp_server/__init__.py +17 -0
- awslabs/dynamodb_mcp_server/cdk_generator/__init__.py +19 -0
- awslabs/dynamodb_mcp_server/cdk_generator/generator.py +276 -0
- awslabs/dynamodb_mcp_server/cdk_generator/models.py +521 -0
- awslabs/dynamodb_mcp_server/cdk_generator/templates/README.md +57 -0
- awslabs/dynamodb_mcp_server/cdk_generator/templates/stack.ts.j2 +70 -0
- awslabs/dynamodb_mcp_server/common.py +94 -0
- awslabs/dynamodb_mcp_server/db_analyzer/__init__.py +30 -0
- awslabs/dynamodb_mcp_server/db_analyzer/analyzer_utils.py +394 -0
- awslabs/dynamodb_mcp_server/db_analyzer/base_plugin.py +355 -0
- awslabs/dynamodb_mcp_server/db_analyzer/mysql.py +450 -0
- awslabs/dynamodb_mcp_server/db_analyzer/plugin_registry.py +73 -0
- awslabs/dynamodb_mcp_server/db_analyzer/postgresql.py +215 -0
- awslabs/dynamodb_mcp_server/db_analyzer/sqlserver.py +255 -0
- awslabs/dynamodb_mcp_server/markdown_formatter.py +513 -0
- awslabs/dynamodb_mcp_server/model_validation_utils.py +845 -0
- awslabs/dynamodb_mcp_server/prompts/dynamodb_architect.md +851 -0
- awslabs/dynamodb_mcp_server/prompts/json_generation_guide.md +185 -0
- awslabs/dynamodb_mcp_server/prompts/transform_model_validation_result.md +168 -0
- awslabs/dynamodb_mcp_server/server.py +524 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/METADATA +306 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/RECORD +27 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/WHEEL +4 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/entry_points.txt +2 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/licenses/LICENSE +175 -0
- awslabs_dynamodb_mcp_server-2.0.10.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,355 @@
|
|
|
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
|
+
#!/usr/bin/env python3
|
|
16
|
+
|
|
17
|
+
"""Base plugin interface for database analyzers."""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from awslabs.dynamodb_mcp_server.common import validate_database_name
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from typing import Any, Dict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DatabasePlugin(ABC):
|
|
27
|
+
"""Base class for database-specific analyzer plugins."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def get_queries(self) -> Dict[str, Any]:
|
|
31
|
+
"""Get all analysis queries for this database type.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Dictionary of query definitions with metadata
|
|
35
|
+
"""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def get_database_display_name(self) -> str:
|
|
40
|
+
"""Get the display name of the database type.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Database type name (e.g., 'MySQL', 'PostgreSQL', 'SQL Server')
|
|
44
|
+
"""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def apply_result_limit(self, sql: str, max_results: int) -> str:
|
|
48
|
+
"""Apply result limit to SQL query.
|
|
49
|
+
|
|
50
|
+
Default implementation uses LIMIT syntax (MySQL/PostgreSQL).
|
|
51
|
+
Override for databases with different syntax (e.g., SQL Server uses TOP).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
sql: SQL query string
|
|
55
|
+
max_results: Maximum number of results
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
SQL query with limit applied
|
|
59
|
+
"""
|
|
60
|
+
sql = sql.rstrip(';')
|
|
61
|
+
return f'{sql} LIMIT {max_results};'
|
|
62
|
+
|
|
63
|
+
def write_queries_to_file(
|
|
64
|
+
self, target_database: str, max_results: int, output_file: str
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Generate SQL file with all analysis queries.
|
|
67
|
+
|
|
68
|
+
Database-specific behavior is handled through get_database_display_name() and
|
|
69
|
+
apply_result_limit() methods.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
target_database: Target database/schema name
|
|
73
|
+
max_results: Maximum results per query
|
|
74
|
+
output_file: Path to output SQL file
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Path to generated file
|
|
78
|
+
"""
|
|
79
|
+
# Validate database name before using it
|
|
80
|
+
validate_database_name(target_database)
|
|
81
|
+
|
|
82
|
+
queries = self.get_queries()
|
|
83
|
+
|
|
84
|
+
sql_content = [
|
|
85
|
+
f'-- {self.get_database_display_name()} Database Analysis Queries',
|
|
86
|
+
f'-- Target Database: {target_database}',
|
|
87
|
+
f'-- Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}',
|
|
88
|
+
'',
|
|
89
|
+
'-- EXECUTION INSTRUCTIONS:',
|
|
90
|
+
'-- 1. Review all queries before execution',
|
|
91
|
+
'-- 2. Run during off-peak hours if possible',
|
|
92
|
+
'-- 3. Each query has a LIMIT clause to prevent excessive results',
|
|
93
|
+
'',
|
|
94
|
+
'-- Generated for DynamoDB Data Modeling\n',
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
total_queries = sum(1 for q in queries.values() if q.get('category') != 'internal')
|
|
98
|
+
query_number = 0
|
|
99
|
+
|
|
100
|
+
for query_name, query_info in queries.items():
|
|
101
|
+
# Skip internal queries
|
|
102
|
+
if query_info.get('category') == 'internal':
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
query_number += 1
|
|
106
|
+
|
|
107
|
+
sql_content.append('')
|
|
108
|
+
sql_content.append('-- ============================================')
|
|
109
|
+
sql_content.append(f'-- QUERY {query_number}/{total_queries}: {query_name}')
|
|
110
|
+
sql_content.append('-- ============================================')
|
|
111
|
+
sql_content.append(f'-- Description: {query_info.get("description", "N/A")}')
|
|
112
|
+
sql_content.append(f'-- Category: {query_info.get("category", "N/A")}')
|
|
113
|
+
|
|
114
|
+
# Add marker as a SELECT statement that outputs to results
|
|
115
|
+
sql_content.append(f"SELECT '-- QUERY_NAME_START: {query_name}' AS marker;")
|
|
116
|
+
|
|
117
|
+
sql = query_info['sql']
|
|
118
|
+
# Substitute target_database parameter
|
|
119
|
+
if 'target_database' in query_info.get('parameters', []):
|
|
120
|
+
sql = sql.format(target_database=target_database)
|
|
121
|
+
|
|
122
|
+
# Apply result limit (database-specific)
|
|
123
|
+
sql = self.apply_result_limit(sql, max_results)
|
|
124
|
+
|
|
125
|
+
sql_content.append(sql)
|
|
126
|
+
|
|
127
|
+
# Add end marker as a SELECT statement
|
|
128
|
+
sql_content.append(f"SELECT '-- QUERY_NAME_END: {query_name}' AS marker;")
|
|
129
|
+
sql_content.append('')
|
|
130
|
+
|
|
131
|
+
# Write to file
|
|
132
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
|
133
|
+
f.write('\n'.join(sql_content))
|
|
134
|
+
|
|
135
|
+
return output_file
|
|
136
|
+
|
|
137
|
+
def parse_results_from_file(self, result_file_path: str) -> Dict[str, Any]:
|
|
138
|
+
"""Parse query results from user-provided file.
|
|
139
|
+
|
|
140
|
+
It parses results with QUERY_NAME_START/END markers and supports
|
|
141
|
+
both pipe-separated and tab-separated formats.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
result_file_path: Path to file containing query results
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Dictionary mapping query names to result data in standard format
|
|
148
|
+
"""
|
|
149
|
+
# Validate path to prevent path traversal attacks
|
|
150
|
+
# Use absolute path and check for path traversal patterns
|
|
151
|
+
if '..' in result_file_path:
|
|
152
|
+
raise ValueError(f'Path traversal detected in result file path: {result_file_path}')
|
|
153
|
+
|
|
154
|
+
result_file_path = os.path.abspath(result_file_path)
|
|
155
|
+
|
|
156
|
+
if not os.path.exists(result_file_path):
|
|
157
|
+
raise FileNotFoundError(f'Result file not found: {result_file_path}')
|
|
158
|
+
|
|
159
|
+
with open(result_file_path, 'r', encoding='utf-8') as f:
|
|
160
|
+
content = f.read()
|
|
161
|
+
|
|
162
|
+
results = {}
|
|
163
|
+
current_query = None
|
|
164
|
+
current_headers = []
|
|
165
|
+
current_data = []
|
|
166
|
+
|
|
167
|
+
lines = content.split('\n')
|
|
168
|
+
i = 0
|
|
169
|
+
|
|
170
|
+
while i < len(lines):
|
|
171
|
+
line = lines[i].strip()
|
|
172
|
+
|
|
173
|
+
# Skip empty lines
|
|
174
|
+
if not line:
|
|
175
|
+
if current_query and current_data:
|
|
176
|
+
results[current_query] = {
|
|
177
|
+
'description': f'Results for {current_query}',
|
|
178
|
+
'data': current_data,
|
|
179
|
+
}
|
|
180
|
+
current_query = None
|
|
181
|
+
current_headers = []
|
|
182
|
+
current_data = []
|
|
183
|
+
i += 1
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# Check for query name markers
|
|
187
|
+
if line.startswith('--'):
|
|
188
|
+
if 'QUERY_NAME_START:' in line:
|
|
189
|
+
# Save previous query if exists
|
|
190
|
+
if current_query and current_data:
|
|
191
|
+
results[current_query] = {
|
|
192
|
+
'description': f'Results for {current_query}',
|
|
193
|
+
'data': current_data,
|
|
194
|
+
}
|
|
195
|
+
# Extract query name from marker
|
|
196
|
+
current_query = line.split('QUERY_NAME_START:')[1].strip()
|
|
197
|
+
current_headers = []
|
|
198
|
+
current_data = []
|
|
199
|
+
elif 'QUERY_NAME_END:' in line:
|
|
200
|
+
# Save current query results (even if empty)
|
|
201
|
+
if current_query:
|
|
202
|
+
results[current_query] = {
|
|
203
|
+
'description': f'Results for {current_query}',
|
|
204
|
+
'data': current_data,
|
|
205
|
+
}
|
|
206
|
+
current_query = None
|
|
207
|
+
current_headers = []
|
|
208
|
+
current_data = []
|
|
209
|
+
i += 1
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
# Skip separator lines
|
|
213
|
+
if all(c in '-+| ' for c in line):
|
|
214
|
+
i += 1
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
# Skip row count lines
|
|
218
|
+
if line.startswith('(') and 'row' in line.lower():
|
|
219
|
+
i += 1
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Parse data row (support both tab and pipe separated)
|
|
223
|
+
if '\t' in line:
|
|
224
|
+
values = [v.strip() for v in line.split('\t')]
|
|
225
|
+
elif '|' in line:
|
|
226
|
+
parts = line.split('|')
|
|
227
|
+
if parts and not parts[0].strip():
|
|
228
|
+
parts = parts[1:]
|
|
229
|
+
if parts and not parts[-1].strip():
|
|
230
|
+
parts = parts[:-1]
|
|
231
|
+
values = [v.strip() for v in parts]
|
|
232
|
+
else:
|
|
233
|
+
i += 1
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
if not values:
|
|
237
|
+
i += 1
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Check if this is a marker row (from SELECT '-- QUERY_NAME_START: ...' AS marker)
|
|
241
|
+
if len(values) > 0 and 'QUERY_NAME_START:' in values[0]:
|
|
242
|
+
# Save previous query if exists
|
|
243
|
+
if current_query and current_data:
|
|
244
|
+
results[current_query] = {
|
|
245
|
+
'description': f'Results for {current_query}',
|
|
246
|
+
'data': current_data,
|
|
247
|
+
}
|
|
248
|
+
# Extract query name from marker value
|
|
249
|
+
marker_text = values[0]
|
|
250
|
+
if 'QUERY_NAME_START:' in marker_text:
|
|
251
|
+
current_query = marker_text.split('QUERY_NAME_START:')[1].strip()
|
|
252
|
+
current_headers = []
|
|
253
|
+
current_data = []
|
|
254
|
+
i += 1
|
|
255
|
+
continue
|
|
256
|
+
elif len(values) > 0 and 'QUERY_NAME_END:' in values[0]:
|
|
257
|
+
# Save current query results (even if empty)
|
|
258
|
+
if current_query:
|
|
259
|
+
results[current_query] = {
|
|
260
|
+
'description': f'Results for {current_query}',
|
|
261
|
+
'data': current_data,
|
|
262
|
+
}
|
|
263
|
+
current_query = None
|
|
264
|
+
current_headers = []
|
|
265
|
+
current_data = []
|
|
266
|
+
i += 1
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
# First line is headers
|
|
270
|
+
if not current_headers:
|
|
271
|
+
current_headers = values
|
|
272
|
+
# Skip if this is the marker column header
|
|
273
|
+
if len(current_headers) == 1 and current_headers[0].lower() == 'marker':
|
|
274
|
+
current_headers = []
|
|
275
|
+
else:
|
|
276
|
+
# Data row
|
|
277
|
+
if len(values) == len(current_headers):
|
|
278
|
+
row_dict = {}
|
|
279
|
+
for header, value in zip(current_headers, values):
|
|
280
|
+
if value.lower() in ['null', 'none', '']:
|
|
281
|
+
row_dict[header] = None
|
|
282
|
+
elif value.replace('.', '', 1).replace('-', '', 1).isdigit():
|
|
283
|
+
try:
|
|
284
|
+
if '.' in value:
|
|
285
|
+
row_dict[header] = float(value)
|
|
286
|
+
else:
|
|
287
|
+
row_dict[header] = int(value)
|
|
288
|
+
except ValueError:
|
|
289
|
+
row_dict[header] = value
|
|
290
|
+
else:
|
|
291
|
+
row_dict[header] = value
|
|
292
|
+
current_data.append(row_dict)
|
|
293
|
+
|
|
294
|
+
i += 1
|
|
295
|
+
|
|
296
|
+
# Save last query
|
|
297
|
+
if current_query and current_data:
|
|
298
|
+
results[current_query] = {
|
|
299
|
+
'description': f'Results for {current_query}',
|
|
300
|
+
'data': current_data,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return results
|
|
304
|
+
|
|
305
|
+
@abstractmethod
|
|
306
|
+
async def execute_managed_mode(self, connection_params: Dict[str, Any]) -> Dict[str, Any]:
|
|
307
|
+
"""Execute analysis in managed mode (direct database connection).
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
connection_params: Database connection parameters
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Analysis result dictionary with results, errors, and metadata
|
|
314
|
+
"""
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
def get_queries_by_category(self, category: str) -> list[str]:
|
|
318
|
+
"""Get list of query names for a specific category.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
category: Query category ('information_schema', 'performance_schema', 'internal')
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
List of query names in the specified category
|
|
325
|
+
"""
|
|
326
|
+
queries = self.get_queries()
|
|
327
|
+
return [
|
|
328
|
+
query_name
|
|
329
|
+
for query_name, query_info in queries.items()
|
|
330
|
+
if query_info.get('category') == category
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
def get_query_descriptions(self) -> Dict[str, str]:
|
|
334
|
+
"""Get mapping of query names to their descriptions.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Dictionary mapping query names to human-readable descriptions
|
|
338
|
+
"""
|
|
339
|
+
queries = self.get_queries()
|
|
340
|
+
descriptions = {}
|
|
341
|
+
for query_name, query_info in queries.items():
|
|
342
|
+
# Skip internal queries (like performance_schema_check)
|
|
343
|
+
if query_info.get('category') != 'internal':
|
|
344
|
+
descriptions[query_name] = query_info.get(
|
|
345
|
+
'description', 'No description available'
|
|
346
|
+
)
|
|
347
|
+
return descriptions
|
|
348
|
+
|
|
349
|
+
def get_schema_queries(self) -> list[str]:
|
|
350
|
+
"""Get list of schema-related query names."""
|
|
351
|
+
return self.get_queries_by_category('information_schema')
|
|
352
|
+
|
|
353
|
+
def get_performance_queries(self) -> list[str]:
|
|
354
|
+
"""Get list of performance-related query names."""
|
|
355
|
+
return self.get_queries_by_category('performance_schema')
|