awslabs.prometheus-mcp-server 0.1.1__py3-none-any.whl → 0.2.1__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.
@@ -29,25 +29,22 @@ from awslabs.prometheus_mcp_server.consts import (
29
29
  DEFAULT_SERVICE_NAME,
30
30
  ENV_AWS_PROFILE,
31
31
  ENV_AWS_REGION,
32
- ENV_AWS_SERVICE_NAME,
33
32
  ENV_LOG_LEVEL,
34
- ENV_PROMETHEUS_URL,
35
33
  SERVER_INSTRUCTIONS,
36
34
  )
37
35
  from awslabs.prometheus_mcp_server.models import (
38
36
  MetricsList,
39
- PrometheusConfig,
40
37
  ServerInfo,
41
38
  )
42
39
  from botocore.auth import SigV4Auth
43
40
  from botocore.awsrequest import AWSRequest
41
+ from botocore.config import Config
44
42
  from botocore.exceptions import ClientError, NoCredentialsError
45
43
  from dotenv import load_dotenv
46
44
  from loguru import logger
47
45
  from mcp.server.fastmcp import Context, FastMCP
48
46
  from pydantic import Field
49
47
  from typing import Any, Dict, Optional
50
- from urllib.parse import urlparse
51
48
 
52
49
 
53
50
  # Configure loguru
@@ -55,347 +52,341 @@ logger.remove()
55
52
  logger.add(sys.stderr, level=os.getenv(ENV_LOG_LEVEL, 'INFO'))
56
53
 
57
54
 
58
- def parse_arguments():
59
- """Parse command line arguments."""
60
- parser = argparse.ArgumentParser(description='Prometheus MCP Server')
61
- parser.add_argument('--profile', type=str, help='AWS profile name to use')
62
- parser.add_argument('--region', type=str, help='AWS region to use')
63
- parser.add_argument('--url', type=str, help='Prometheus URL')
64
- parser.add_argument('--config', type=str, help='Path to configuration file')
65
- parser.add_argument('--debug', action='store_true', help='Enable debug logging')
66
- return parser.parse_args()
67
-
68
-
69
- def load_config(args):
70
- """Load configuration from file, environment variables, and command line arguments."""
71
- # Load .env file if it exists
72
- load_dotenv()
73
-
74
- # Initialize config with default values
75
- config_data = {
76
- 'aws_profile': None,
77
- 'aws_region': DEFAULT_AWS_REGION,
78
- 'prometheus_url': '',
79
- 'service_name': DEFAULT_SERVICE_NAME,
80
- 'max_retries': DEFAULT_MAX_RETRIES,
81
- 'retry_delay': DEFAULT_RETRY_DELAY,
82
- }
83
-
84
- # Load from config file if specified
85
- if args.config and os.path.exists(args.config):
86
- try:
87
- with open(args.config, 'r') as f:
88
- file_config = json.load(f)
89
- config_data.update(file_config)
90
- logger.info(f'Loaded configuration from {args.config}')
91
- except Exception as e:
92
- logger.error(f'Error loading config file: {e}')
93
-
94
- # Override with environment variables
95
- if os.getenv(ENV_AWS_PROFILE):
96
- config_data['aws_profile'] = os.getenv(ENV_AWS_PROFILE)
97
- if os.getenv(ENV_AWS_REGION):
98
- config_data['aws_region'] = os.getenv(ENV_AWS_REGION)
99
- if os.getenv(ENV_PROMETHEUS_URL):
100
- config_data['prometheus_url'] = os.getenv(ENV_PROMETHEUS_URL)
101
- if os.getenv(ENV_AWS_SERVICE_NAME):
102
- config_data['service_name'] = os.getenv(ENV_AWS_SERVICE_NAME)
103
-
104
- # Override with command line arguments
105
- if args.profile:
106
- config_data['aws_profile'] = args.profile
107
- if args.region:
108
- config_data['aws_region'] = args.region
109
- if args.url:
110
- config_data['prometheus_url'] = args.url
111
- if args.debug:
112
- logger.level('DEBUG')
113
- logger.debug('Debug logging enabled')
114
-
115
- return config_data
116
-
117
-
118
- def setup_environment(config):
119
- """Setup and validate environment variables."""
120
- logger.info('Setting up environment...')
121
-
122
- # Validate Prometheus URL
123
- if not config['prometheus_url']:
124
- logger.error(
125
- 'ERROR: Prometheus URL not configured. Please set using --url parameter or PROMETHEUS_URL environment variable.'
126
- )
127
- return False
55
+ class ConfigManager:
56
+ """Configuration management for the application."""
128
57
 
129
- try:
130
- parsed_url = urlparse(config['prometheus_url'])
131
- if not all([parsed_url.scheme, parsed_url.netloc]):
132
- logger.error(f'ERROR: Invalid Prometheus URL format: {config["prometheus_url"]}')
133
- logger.error('URL must include scheme (https://) and hostname')
134
- return False
58
+ @staticmethod
59
+ def parse_arguments():
60
+ """Parse command line arguments."""
61
+ parser = argparse.ArgumentParser(description='Prometheus MCP Server')
62
+ parser.add_argument('--profile', type=str, help='AWS profile name to use')
63
+ parser.add_argument('--region', type=str, help='AWS region to use')
64
+ parser.add_argument('--url', type=str, help='Prometheus URL to use')
65
+ parser.add_argument('--debug', action='store_true', help='Enable debug logging')
66
+ return parser.parse_args()
135
67
 
136
- # Verify URL points to AWS Prometheus
137
- if not (
138
- parsed_url.netloc.endswith('.amazonaws.com') and 'aps-workspaces' in parsed_url.netloc
139
- ):
140
- logger.warning(
141
- f"WARNING: URL doesn't appear to be an AWS Managed Prometheus endpoint: {config['prometheus_url']}"
142
- )
143
- logger.warning(
144
- 'Expected format: https://aps-workspaces.[region].amazonaws.com/workspaces/ws-[id]'
145
- )
146
- except Exception as e:
147
- logger.error(f'ERROR: Error parsing Prometheus URL: {e}')
148
- return False
68
+ @staticmethod
69
+ def setup_basic_config(args):
70
+ """Setup basic configuration from command line arguments and environment variables."""
71
+ # Load .env file if it exists
72
+ load_dotenv()
149
73
 
150
- logger.info('Prometheus configuration:')
151
- logger.info(f' Server URL: {config["prometheus_url"]}')
152
- logger.info(f' AWS Region: {config["aws_region"]}')
74
+ # Set debug logging if requested
75
+ if args.debug:
76
+ logger.level('DEBUG')
77
+ logger.debug('Debug logging enabled')
153
78
 
154
- # Test AWS credentials
155
- try:
156
- if not config['aws_region']:
157
- logger.error(
158
- 'ERROR: AWS region not configured. Please set using --region parameter or AWS_REGION environment variable.'
159
- )
160
- return False
161
-
162
- logger.info(f' AWS Region: {config["aws_region"]}')
163
-
164
- # Create session with profile if specified
165
- if config['aws_profile']:
166
- logger.info(f' Using AWS Profile: {config["aws_profile"]}')
167
- session = boto3.Session(
168
- profile_name=config['aws_profile'], region_name=config['aws_region']
169
- )
170
- else:
171
- logger.info(' Using default AWS credentials')
172
- session = boto3.Session(region_name=config['aws_region'])
173
-
174
- credentials = session.get_credentials()
175
- if credentials:
176
- logger.info(' AWS Credentials: Available')
177
- if credentials.token:
178
- logger.info(' Credential Type: Temporary (includes session token)')
179
- else:
180
- logger.info(' Credential Type: Long-term')
79
+ # Get region, profile, and URL from args or environment
80
+ region = args.region or os.getenv(ENV_AWS_REGION) or DEFAULT_AWS_REGION
81
+ profile = args.profile or os.getenv(ENV_AWS_PROFILE)
82
+ url = args.url or os.getenv('PROMETHEUS_URL')
181
83
 
182
- # Test if credentials have necessary permissions
183
- try:
184
- sts = session.client('sts')
185
- identity = sts.get_caller_identity()
186
- logger.info(f' AWS Identity: {identity["Arn"]}')
187
- except ClientError as e:
188
- logger.warning(f'WARNING: Could not verify AWS identity: {e}')
189
- logger.warning(
190
- 'This may indicate insufficient permissions for STS:GetCallerIdentity'
191
- )
192
- else:
193
- logger.error('ERROR: AWS Credentials not found')
194
- logger.error('Please configure AWS credentials using:')
195
- logger.error(' - AWS CLI: aws configure')
196
- logger.error(' - Environment variables: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY')
197
- logger.error(' - Or specify a profile with --profile')
198
- return False
199
- except NoCredentialsError:
200
- logger.error('ERROR: AWS credentials not found')
201
- logger.error('Please configure AWS credentials using:')
202
- logger.error(' - AWS CLI: aws configure')
203
- logger.error(' - Environment variables: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY')
204
- logger.error(' - Or specify a profile with --profile')
205
- return False
206
- except Exception as e:
207
- logger.error(f'ERROR: Error setting up AWS session: {e}')
208
- return False
84
+ return {'region': region, 'profile': profile, 'url': url}
209
85
 
210
- return True
211
86
 
87
+ class AWSCredentials:
88
+ """AWS credentials management."""
212
89
 
213
- def validate_params(params: Dict) -> bool:
214
- """Validate request parameters for potential security issues.
90
+ @staticmethod
91
+ def validate(region: str, profile: Optional[str] = None) -> bool:
92
+ """Validate AWS credentials.
215
93
 
216
- Args:
217
- params: The parameters to validate
94
+ Args:
95
+ region: AWS region to use
96
+ profile: AWS profile to use (optional)
218
97
 
219
- Returns:
220
- bool: True if the parameters are safe, False otherwise
221
- """
222
- if not params:
223
- return True
98
+ Returns:
99
+ bool: True if credentials are valid, False otherwise
100
+ """
101
+ logger.info('Validating AWS credentials...')
224
102
 
225
- # List of dangerous patterns to check for
226
- dangerous_patterns = [
227
- # Command injection attempts
228
- ';',
229
- '&&',
230
- '||',
231
- '`',
232
- '$(',
233
- '${',
234
- # File access attempts
235
- 'file://',
236
- '/etc/',
237
- '/var/log',
238
- # Network access attempts
239
- 'http://',
240
- 'https://',
241
- ]
242
-
243
- # Check each parameter value
244
- for key, value in params.items():
245
- if not isinstance(value, str):
246
- continue
247
-
248
- for pattern in dangerous_patterns:
249
- if pattern in value:
250
- logger.warning(f'Potentially dangerous parameter detected: {key}={value}')
103
+ try:
104
+ # Create session with profile if specified
105
+ if profile:
106
+ logger.info(f'Using AWS Profile: {profile}')
107
+ session = boto3.Session(profile_name=profile, region_name=region)
108
+ else:
109
+ logger.info('Using default AWS credentials')
110
+ session = boto3.Session(region_name=region)
111
+
112
+ # Test AWS credentials
113
+ credentials = session.get_credentials()
114
+ if not credentials:
115
+ logger.error('ERROR: AWS credentials not found')
116
+ logger.error('Please configure AWS credentials using:')
117
+ logger.error(' - AWS CLI: aws configure')
118
+ logger.error(
119
+ ' - Environment variables: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY'
120
+ )
121
+ logger.error(' - Or specify a profile with --profile')
251
122
  return False
252
123
 
253
- return True
254
-
255
-
256
- async def make_prometheus_request(
257
- endpoint: str, params: Optional[Dict] = None, max_retries: int = 3
258
- ) -> Any:
259
- """Make a request to the Prometheus HTTP API with AWS SigV4 authentication.
260
-
261
- Args:
262
- endpoint: The Prometheus API endpoint to call
263
- params: Query parameters to include in the request
264
- max_retries: Maximum number of retry attempts
265
-
266
- Returns:
267
- The data portion of the Prometheus API response
268
-
269
- Raises:
270
- ValueError: If Prometheus URL or AWS credentials are not configured
271
- RuntimeError: If the Prometheus API returns an error status
272
- requests.RequestException: If there's a network or HTTP error
273
- json.JSONDecodeError: If the response is not valid JSON
274
- """
275
- if not config or not config.prometheus_url:
276
- raise ValueError('Prometheus URL not configured')
277
-
278
- # Validate endpoint
279
- if not isinstance(endpoint, str):
280
- raise ValueError('Endpoint must be a string')
124
+ # Test if credentials have necessary permissions
125
+ sts = session.client('sts', config=Config(user_agent_extra='prometheus-mcp-server'))
126
+ identity = sts.get_caller_identity()
127
+ logger.info(f'AWS Identity: {identity["Arn"]}')
128
+ logger.info(f'AWS Region: {region}')
129
+ logger.info('AWS credentials validated successfully')
130
+ return True
131
+
132
+ except (NoCredentialsError, ClientError) as e:
133
+ logger.error(f'ERROR: AWS credentials validation failed: {e}')
134
+ return False
281
135
 
282
- if ';' in endpoint or '&&' in endpoint or '||' in endpoint:
283
- raise ValueError('Invalid endpoint: potentially dangerous characters detected')
284
136
 
285
- # Validate parameters
286
- if params and not validate_params(params):
287
- raise ValueError('Invalid parameters: potentially dangerous values detected')
137
+ # Define dangerous patterns as a constant
138
+ DANGEROUS_PATTERNS = [
139
+ # Command injection attempts
140
+ ';',
141
+ '&&',
142
+ '||',
143
+ '`',
144
+ '$(',
145
+ '${',
146
+ # File access attempts
147
+ 'file://',
148
+ '/etc/',
149
+ '/var/log',
150
+ # Network access attempts
151
+ 'http://',
152
+ 'https://',
153
+ ]
154
+
155
+
156
+ class SecurityValidator:
157
+ """Security validation utilities."""
158
+
159
+ @staticmethod
160
+ def validate_string(value: str, context: str = 'value') -> bool:
161
+ """Validate a string for potential security issues.
162
+
163
+ Args:
164
+ value: The string to validate
165
+ context: Context description for logging (e.g., 'parameter', 'query')
166
+
167
+ Returns:
168
+ bool: True if the string is safe, False otherwise
169
+ """
170
+ # Check for dangerous patterns
171
+ for pattern in DANGEROUS_PATTERNS:
172
+ if pattern in value:
173
+ logger.warning(f'Potentially dangerous {context} detected: {pattern}')
174
+ return False
288
175
 
289
- # Ensure the URL ends with /api/v1
290
- base_url = config.prometheus_url
291
- if not base_url.endswith(API_VERSION_PATH):
292
- base_url = f'{base_url.rstrip("/")}{API_VERSION_PATH}'
176
+ return True
293
177
 
294
- url = f'{base_url}/{endpoint.lstrip("/")}'
178
+ @staticmethod
179
+ def validate_params(params: Dict) -> bool:
180
+ """Validate request parameters for potential security issues.
295
181
 
296
- # Create AWS request
297
- aws_request = AWSRequest(method='GET', url=url, params=params or {})
182
+ Args:
183
+ params: The parameters to validate
298
184
 
299
- # Sign request with SigV4
300
- session = boto3.Session(profile_name=config.aws_profile, region_name=config.aws_region)
301
- credentials = session.get_credentials()
302
- if not credentials:
303
- raise ValueError('AWS credentials not found')
185
+ Returns:
186
+ bool: True if the parameters are safe, False otherwise
187
+ """
188
+ if not params:
189
+ return True
304
190
 
305
- SigV4Auth(credentials, config.service_name, config.aws_region).add_auth(aws_request)
191
+ # Check each parameter value
192
+ for key, value in params.items():
193
+ if not isinstance(value, str):
194
+ continue
306
195
 
307
- # Convert to requests format
308
- prepared_request = requests.Request(
309
- method=aws_request.method,
310
- url=aws_request.url,
311
- headers=dict(aws_request.headers),
312
- params=params or {},
313
- ).prepare()
196
+ if not SecurityValidator.validate_string(value, f'parameter {key}'):
197
+ return False
314
198
 
315
- # Send request with retry logic
316
- retry_count = 0
317
- last_exception = None
318
- retry_delay_seconds = 1 # Default retry delay if config.retry_delay is None
199
+ return True
319
200
 
320
- while retry_count < max_retries:
321
- try:
322
- with requests.Session() as session:
323
- logger.debug(f'Making request to {url} (attempt {retry_count + 1}/{max_retries})')
324
- response = session.send(prepared_request)
325
- response.raise_for_status()
326
- data = response.json()
327
-
328
- if data['status'] != 'success':
329
- error_msg = data.get('error', 'Unknown error')
330
- logger.error(f'Prometheus API request failed: {error_msg}')
331
- raise RuntimeError(f'Prometheus API request failed: {error_msg}')
332
-
333
- return data['data']
334
- except (requests.RequestException, json.JSONDecodeError) as e:
335
- last_exception = e
336
- retry_count += 1
337
- if retry_count < max_retries:
338
- if config and hasattr(config, 'retry_delay') and config.retry_delay is not None:
339
- retry_delay_seconds = config.retry_delay * (
201
+ @staticmethod
202
+ def validate_query(query: str) -> bool:
203
+ """Validate a PromQL query for potential security issues.
204
+
205
+ Args:
206
+ query: The PromQL query to validate
207
+
208
+ Returns:
209
+ bool: True if the query is safe, False otherwise
210
+ """
211
+ return SecurityValidator.validate_string(query, 'query pattern')
212
+
213
+
214
+ class PrometheusClient:
215
+ """Client for interacting with Prometheus API."""
216
+
217
+ @staticmethod
218
+ async def make_request(
219
+ prometheus_url: str,
220
+ endpoint: str,
221
+ params: Optional[Dict] = None,
222
+ region: str = DEFAULT_AWS_REGION,
223
+ profile: Optional[str] = None,
224
+ max_retries: int = DEFAULT_MAX_RETRIES,
225
+ retry_delay: int = DEFAULT_RETRY_DELAY,
226
+ service_name: str = DEFAULT_SERVICE_NAME,
227
+ ) -> Any:
228
+ """Make a request to the Prometheus HTTP API with AWS SigV4 authentication.
229
+
230
+ Args:
231
+ prometheus_url: The base URL for the Prometheus API
232
+ endpoint: The Prometheus API endpoint to call
233
+ params: Query parameters to include in the request
234
+ region: AWS region to use
235
+ profile: AWS profile to use
236
+ max_retries: Maximum number of retry attempts
237
+ retry_delay: Delay between retry attempts in seconds
238
+ service_name: AWS service name for SigV4 authentication
239
+
240
+ Returns:
241
+ The data portion of the Prometheus API response
242
+
243
+ Raises:
244
+ ValueError: If Prometheus URL or AWS credentials are not configured
245
+ RuntimeError: If the Prometheus API returns an error status
246
+ requests.RequestException: If there's a network or HTTP error
247
+ json.JSONDecodeError: If the response is not valid JSON
248
+ """
249
+ if not prometheus_url:
250
+ raise ValueError('Prometheus URL not configured')
251
+
252
+ # Validate endpoint
253
+ if not isinstance(endpoint, str):
254
+ raise ValueError('Endpoint must be a string')
255
+
256
+ if ';' in endpoint or '&&' in endpoint or '||' in endpoint:
257
+ raise ValueError('Invalid endpoint: potentially dangerous characters detected')
258
+
259
+ # Validate parameters
260
+ if params and not SecurityValidator.validate_params(params):
261
+ raise ValueError('Invalid parameters: potentially dangerous values detected')
262
+
263
+ # Ensure the URL ends with /api/v1
264
+ base_url = prometheus_url
265
+ if not base_url.endswith(API_VERSION_PATH):
266
+ base_url = f'{base_url.rstrip("/")}{API_VERSION_PATH}'
267
+
268
+ url = f'{base_url}/{endpoint.lstrip("/")}'
269
+
270
+ # Send request with retry logic
271
+ retry_count = 0
272
+ last_exception = None
273
+ retry_delay_seconds = retry_delay
274
+
275
+ while retry_count < max_retries:
276
+ try:
277
+ # Create a fresh session and client for each attempt
278
+ session = boto3.Session(profile_name=profile, region_name=region)
279
+ credentials = session.get_credentials()
280
+ if not credentials:
281
+ raise ValueError('AWS credentials not found')
282
+
283
+ # Create and sign the request
284
+ aws_request = AWSRequest(method='GET', url=url, params=params or {})
285
+ SigV4Auth(credentials, service_name, region).add_auth(aws_request)
286
+
287
+ # Convert to requests format
288
+ prepared_request = requests.Request(
289
+ method=aws_request.method,
290
+ url=aws_request.url,
291
+ headers=dict(aws_request.headers),
292
+ params=params or {},
293
+ ).prepare()
294
+
295
+ # Send the request
296
+ with requests.Session() as req_session:
297
+ logger.debug(
298
+ f'Making request to {url} (attempt {retry_count + 1}/{max_retries})'
299
+ )
300
+ response = req_session.send(prepared_request)
301
+ response.raise_for_status()
302
+ data = response.json()
303
+
304
+ if data['status'] != 'success':
305
+ error_msg = data.get('error', 'Unknown error')
306
+ logger.error(f'Prometheus API request failed: {error_msg}')
307
+ raise RuntimeError(f'Prometheus API request failed: {error_msg}')
308
+
309
+ return data['data']
310
+ except (requests.RequestException, json.JSONDecodeError) as e:
311
+ last_exception = e
312
+ retry_count += 1
313
+ if retry_count < max_retries:
314
+ retry_delay_seconds = retry_delay * (
340
315
  2 ** (retry_count - 1)
341
316
  ) # Exponential backoff
317
+ logger.warning(f'Request failed: {e}. Retrying in {retry_delay_seconds}s...')
318
+ time.sleep(retry_delay_seconds)
342
319
  else:
343
- retry_delay_seconds = 1 * (
344
- 2 ** (retry_count - 1)
345
- ) # Default exponential backoff
346
- logger.warning(f'Request failed: {e}. Retrying in {retry_delay_seconds}s...')
347
- time.sleep(retry_delay_seconds)
348
- else:
349
- logger.error(f'Request failed after {max_retries} attempts: {e}')
350
- raise
320
+ logger.error(f'Request failed after {max_retries} attempts: {e}')
321
+ raise
351
322
 
352
- if last_exception:
353
- raise last_exception
354
- return None
323
+ if last_exception:
324
+ raise last_exception
325
+ return None
355
326
 
356
327
 
357
- async def test_prometheus_connection():
358
- """Test the connection to Prometheus.
328
+ class PrometheusConnection:
329
+ """Handles Prometheus connection testing."""
359
330
 
360
- Returns:
361
- bool: True if connection is successful, False otherwise
362
- """
363
- logger.info('Testing Prometheus connection...')
364
- try:
365
- await make_prometheus_request('label/__name__/values', params={})
366
- logger.info('Successfully connected to Prometheus!')
367
- return True
368
- except ClientError as e:
369
- error_code = e.response.get('Error', {}).get('Code', 'Unknown')
370
- if error_code == 'AccessDeniedException':
371
- logger.error('ERROR: Access denied when connecting to Prometheus')
372
- logger.error('Please check that your AWS credentials have the following permissions:')
373
- logger.error(' - aps:QueryMetrics')
374
- logger.error(' - aps:GetLabels')
375
- logger.error(' - aps:GetMetricMetadata')
376
- elif error_code == 'ResourceNotFoundException':
377
- logger.error('ERROR: Prometheus workspace not found')
378
- prometheus_url = 'Not configured'
379
- if config and hasattr(config, 'prometheus_url') and config.prometheus_url:
380
- prometheus_url = config.prometheus_url
381
- logger.error(
382
- f'Please verify the workspace ID in your Prometheus URL: {prometheus_url}'
331
+ @staticmethod
332
+ async def test_connection(
333
+ prometheus_url: str, region: str = DEFAULT_AWS_REGION, profile: Optional[str] = None
334
+ ) -> bool:
335
+ """Test the connection to Prometheus.
336
+
337
+ Args:
338
+ prometheus_url: The Prometheus URL to test
339
+ region: AWS region to use
340
+ profile: AWS profile to use
341
+
342
+ Returns:
343
+ bool: True if connection is successful, False otherwise
344
+ """
345
+ logger.info('Testing Prometheus connection...')
346
+ try:
347
+ # Use the PrometheusClient.make_request method
348
+ await PrometheusClient.make_request(
349
+ prometheus_url=prometheus_url,
350
+ endpoint='label/__name__/values',
351
+ params={},
352
+ region=region,
353
+ profile=profile,
354
+ max_retries=DEFAULT_MAX_RETRIES,
355
+ retry_delay=DEFAULT_RETRY_DELAY,
356
+ service_name=DEFAULT_SERVICE_NAME,
383
357
  )
384
- else:
385
- logger.error(f'ERROR: AWS API error when connecting to Prometheus: {error_code}')
386
- logger.error(f'Details: {str(e)}')
387
- return False
388
- except requests.RequestException as e:
389
- logger.error(f'ERROR: Network error when connecting to Prometheus: {str(e)}')
390
- logger.error('Please check your network connection and Prometheus URL')
391
- return False
392
- except Exception as e:
393
- logger.error(f'ERROR: Error connecting to Prometheus: {str(e)}')
394
- logger.error('Common issues:')
395
- logger.error('1. Incorrect Prometheus URL')
396
- logger.error('2. Missing or incorrect AWS region')
397
- logger.error('3. Invalid AWS credentials or insufficient permissions')
398
- return False
358
+ logger.info('Successfully connected to Prometheus!')
359
+ return True
360
+ except ClientError as e:
361
+ error_code = e.response.get('Error', {}).get('Code', 'Unknown')
362
+ if error_code == 'AccessDeniedException':
363
+ logger.error('ERROR: Access denied when connecting to Prometheus')
364
+ logger.error(
365
+ 'Please check that your AWS credentials have the following permissions:'
366
+ )
367
+ logger.error(' - aps:QueryMetrics')
368
+ logger.error(' - aps:GetLabels')
369
+ logger.error(' - aps:GetMetricMetadata')
370
+ elif error_code == 'ResourceNotFoundException':
371
+ logger.error('ERROR: Prometheus workspace not found')
372
+ logger.error(
373
+ f'Please verify the workspace ID in your Prometheus URL: {prometheus_url}'
374
+ )
375
+ else:
376
+ logger.error(f'ERROR: AWS API error when connecting to Prometheus: {error_code}')
377
+ logger.error(f'Details: {str(e)}')
378
+ return False
379
+ except requests.RequestException as e:
380
+ logger.error(f'ERROR: Network error when connecting to Prometheus: {str(e)}')
381
+ logger.error('Please check your network connection and Prometheus URL')
382
+ return False
383
+ except Exception as e:
384
+ logger.error(f'ERROR: Error connecting to Prometheus: {str(e)}')
385
+ logger.error('Common issues:')
386
+ logger.error('1. Incorrect Prometheus URL')
387
+ logger.error('2. Missing or incorrect AWS region')
388
+ logger.error('3. Invalid AWS credentials or insufficient permissions')
389
+ return False
399
390
 
400
391
 
401
392
  # Initialize MCP
@@ -411,73 +402,137 @@ mcp = FastMCP(
411
402
  ],
412
403
  )
413
404
 
414
- # Global config object
415
- config = None # Will be initialized in main()
405
+ # No global configuration - using environment variables instead
416
406
 
417
407
 
418
- @mcp.tool(name='ExecuteQuery')
419
- def validate_query(query: str) -> bool:
420
- """Validate a PromQL query for potential security issues.
408
+ def get_prometheus_client(region_name: Optional[str] = None, profile_name: Optional[str] = None):
409
+ """Create a boto3 AMP client using credentials from environment variables.
421
410
 
422
411
  Args:
423
- query: The PromQL query to validate
412
+ region_name: AWS region to use (defaults to environment variable or us-east-1)
413
+ profile_name: AWS profile to use (defaults to None)
424
414
 
425
415
  Returns:
426
- bool: True if the query is safe, False otherwise
416
+ boto3 AMP client with fresh credentials
417
+ """
418
+ # Use provided region, or get from env, or fall back to default
419
+ region = region_name or os.getenv('AWS_REGION') or DEFAULT_AWS_REGION
420
+
421
+ # Configure custom user agent
422
+ config = Config(user_agent_extra='prometheus-mcp-server')
423
+
424
+ # Create a new session to force credentials to reload
425
+ session = boto3.Session(profile_name=profile_name, region_name=region)
426
+
427
+ # Return AMP client
428
+ return session.client('amp', config=config)
429
+
427
430
 
428
- This function checks for potentially dangerous patterns in PromQL queries.
431
+ async def get_workspace_details(
432
+ workspace_id: str, region: str = DEFAULT_AWS_REGION, profile: Optional[str] = None
433
+ ) -> Dict[str, Any]:
434
+ """Get details for a specific Prometheus workspace using DescribeWorkspace API.
435
+
436
+ Args:
437
+ workspace_id: The Prometheus workspace ID
438
+ region: AWS region where the workspace is located
439
+ profile: AWS profile to use (defaults to None)
440
+
441
+ Returns:
442
+ Dictionary containing workspace details including URL from API
429
443
  """
430
- # List of dangerous patterns to check for
431
- dangerous_patterns = [
432
- # Command injection attempts
433
- ';',
434
- '&&',
435
- '||',
436
- '`',
437
- '$(',
438
- '${',
439
- # File access attempts
440
- 'file://',
441
- '/etc/',
442
- '/var/log',
443
- # Network access attempts
444
- 'http://',
445
- 'https://',
446
- ]
447
-
448
- # Check for dangerous patterns
449
- for pattern in dangerous_patterns:
450
- if pattern in query:
451
- logger.warning(f'Potentially dangerous query pattern detected: {pattern}')
452
- return False
444
+ # Get a fresh client for this request
445
+ aps_client = get_prometheus_client(region_name=region, profile_name=profile)
453
446
 
454
- return True
447
+ try:
448
+ # Get workspace details directly from DescribeWorkspace API
449
+ response = aps_client.describe_workspace(workspaceId=workspace_id)
450
+ workspace = response.get('workspace', {})
451
+
452
+ # Get the URL from the API response
453
+ prometheus_url = workspace.get('prometheusEndpoint')
454
+ if not prometheus_url:
455
+ raise ValueError(
456
+ f'No prometheusEndpoint found in workspace response for {workspace_id}'
457
+ )
458
+
459
+ logger.info(f'Retrieved workspace URL from DescribeWorkspace API: {prometheus_url}')
460
+
461
+ return {
462
+ 'workspace_id': workspace_id,
463
+ 'alias': workspace.get('alias', 'No alias'),
464
+ 'status': workspace.get('status', {}).get('statusCode', 'UNKNOWN'),
465
+ 'prometheus_url': prometheus_url,
466
+ 'region': region,
467
+ }
468
+ except Exception as e:
469
+ logger.error(f'Error in DescribeWorkspace API: {str(e)}')
470
+ raise
455
471
 
456
472
 
473
+ # validate_query function removed - now part of SecurityValidator class
474
+
475
+
476
+ @mcp.tool(name='ExecuteQuery')
457
477
  async def execute_query(
458
478
  ctx: Context,
479
+ workspace_id: Optional[str] = Field(
480
+ None,
481
+ description='The Prometheus workspace ID to use (e.g., ws-12345678-abcd-1234-efgh-123456789012). Optional if a URL is configured via command line arguments.',
482
+ ),
459
483
  query: str = Field(..., description='The PromQL query to execute'),
460
484
  time: Optional[str] = Field(
461
485
  None, description='Optional timestamp for query evaluation (RFC3339 or Unix timestamp)'
462
486
  ),
487
+ region: Optional[str] = Field(None, description='AWS region (defaults to current region)'),
488
+ profile: Optional[str] = Field(None, description='AWS profile to use (defaults to None)'),
463
489
  ) -> Dict[str, Any]:
464
- """Execute an instant query and return the result.
490
+ """Execute a PromQL query against Amazon Managed Prometheus.
465
491
 
466
492
  ## Usage
467
493
  - Use this tool to execute a PromQL query at a specific instant in time
468
494
  - The query will return the current value of the specified metrics
469
495
  - For time series data over a range, use execute_range_query instead
496
+ - If workspace_id is not known, use GetAvailableWorkspaces tool first to find available workspaces and ASK THE USER to choose one
497
+ - Uses DescribeWorkspace API to get the exact workspace URL
498
+ - No manual URL construction is performed
470
499
 
471
- ## Example queries
500
+ ## Example
501
+ Input:
502
+ workspace_id: "ws-12345678-abcd-1234-efgh-123456789012"
503
+ query: "up"
504
+ region: "us-east-1"
505
+
506
+ Output:
507
+ {
508
+ "resultType": "vector",
509
+ "result": [
510
+ {
511
+ "metric": {"__name__": "up", "instance": "localhost:9090", "job": "prometheus"},
512
+ "value": [1680307200, "1"]
513
+ },
514
+ {
515
+ "metric": {"__name__": "up", "instance": "localhost:9100", "job": "node"},
516
+ "value": [1680307200, "1"]
517
+ }
518
+ ]
519
+ }
520
+
521
+ Example queries:
472
522
  - `up` - Shows which targets are up
473
523
  - `rate(node_cpu_seconds_total{mode="system"}[1m])` - CPU usage rate
474
524
  - `sum by(instance) (rate(node_network_receive_bytes_total[5m]))` - Network receive rate by instance
475
525
  """
476
526
  try:
527
+ # Configure workspace using the provided workspace_id
528
+ workspace_config = await configure_workspace_for_request(
529
+ ctx, workspace_id, region, profile
530
+ )
531
+
477
532
  logger.info(f'Executing instant query: {query}')
478
533
 
479
534
  # Validate query for security
480
- if not validate_query(query):
535
+ if not SecurityValidator.validate_query(query):
481
536
  error_msg = 'Query validation failed: potentially dangerous query pattern detected'
482
537
  logger.error(error_msg)
483
538
  await ctx.error(error_msg)
@@ -487,11 +542,16 @@ async def execute_query(
487
542
  if time:
488
543
  params['time'] = time
489
544
 
490
- max_retries = 3 # Default value
491
- if config and hasattr(config, 'max_retries') and config.max_retries is not None:
492
- max_retries = config.max_retries
493
-
494
- return await make_prometheus_request('query', params, max_retries)
545
+ return await PrometheusClient.make_request(
546
+ prometheus_url=workspace_config['prometheus_url'],
547
+ endpoint='query',
548
+ params=params,
549
+ region=workspace_config['region'],
550
+ profile=workspace_config['profile'],
551
+ max_retries=DEFAULT_MAX_RETRIES,
552
+ retry_delay=DEFAULT_RETRY_DELAY,
553
+ service_name=DEFAULT_SERVICE_NAME,
554
+ )
495
555
  except Exception as e:
496
556
  error_msg = f'Error executing query: {str(e)}'
497
557
  logger.error(error_msg)
@@ -502,33 +562,58 @@ async def execute_query(
502
562
  @mcp.tool(name='ExecuteRangeQuery')
503
563
  async def execute_range_query(
504
564
  ctx: Context,
565
+ workspace_id: Optional[str] = Field(
566
+ None,
567
+ description='The Prometheus workspace ID to use (e.g., ws-12345678-abcd-1234-efgh-123456789012). Optional if a URL is configured via command line arguments.',
568
+ ),
505
569
  query: str = Field(..., description='The PromQL query to execute'),
506
570
  start: str = Field(..., description='Start timestamp (RFC3339 or Unix timestamp)'),
507
571
  end: str = Field(..., description='End timestamp (RFC3339 or Unix timestamp)'),
508
572
  step: str = Field(
509
573
  ..., description="Query resolution step width (duration format, e.g. '15s', '1m', '1h')"
510
574
  ),
575
+ region: Optional[str] = Field(None, description='AWS region (defaults to current region)'),
576
+ profile: Optional[str] = Field(None, description='AWS profile to use (defaults to None)'),
511
577
  ) -> Dict[str, Any]:
512
- """Execute a range query and return the result.
578
+ r"""Execute a range query and return the result.
513
579
 
514
580
  ## Usage
515
581
  - Use this tool to execute a PromQL query over a time range
516
582
  - The query will return a series of values for the specified time range
517
583
  - Useful for generating time series data for graphs or trend analysis
584
+ - If workspace_id is not known, use GetAvailableWorkspaces tool first to find available workspaces and ASK THE USER to choose one
585
+ - Uses DescribeWorkspace API to get the exact workspace URL
586
+ - No manual URL construction is performed
518
587
 
519
588
  ## Example
520
- - Query: `rate(node_cpu_seconds_total{mode="system"}[5m])`
521
- - Start: `2023-04-01T00:00:00Z`
522
- - End: `2023-04-01T01:00:00Z`
523
- - Step: `5m`
524
-
525
- This will return CPU usage rate sampled every 5 minutes over a 1-hour period.
589
+ Input:
590
+ workspace_id: "ws-12345678-abcd-1234-efgh-123456789012"
591
+ query: "rate(node_cpu_seconds_total{mode=\"system\"}[5m])"
592
+ start: "2023-04-01T00:00:00Z"
593
+ end: "2023-04-01T01:00:00Z"
594
+ step: "5m"
595
+
596
+ Output:
597
+ {
598
+ "resultType": "matrix",
599
+ "result": [
600
+ {
601
+ "metric": {"__name__": "rate", "mode": "system", "instance": "localhost:9100"},
602
+ "values": [[1680307200, "0.01"], [1680307500, "0.012"], ...]
603
+ }
604
+ ]
605
+ }
526
606
  """
527
607
  try:
608
+ # Configure workspace using the provided workspace_id
609
+ workspace_config = await configure_workspace_for_request(
610
+ ctx, workspace_id, region, profile
611
+ )
612
+
528
613
  logger.info(f'Executing range query: {query} from {start} to {end} with step {step}')
529
614
 
530
615
  # Validate query for security
531
- if not validate_query(query):
616
+ if not SecurityValidator.validate_query(query):
532
617
  error_msg = 'Query validation failed: potentially dangerous query pattern detected'
533
618
  logger.error(error_msg)
534
619
  await ctx.error(error_msg)
@@ -536,11 +621,16 @@ async def execute_range_query(
536
621
 
537
622
  params = {'query': query, 'start': start, 'end': end, 'step': step}
538
623
 
539
- max_retries = 3 # Default value
540
- if config and hasattr(config, 'max_retries') and config.max_retries is not None:
541
- max_retries = config.max_retries
542
-
543
- return await make_prometheus_request('query_range', params, max_retries)
624
+ return await PrometheusClient.make_request(
625
+ prometheus_url=workspace_config['prometheus_url'],
626
+ endpoint='query_range',
627
+ params=params,
628
+ region=workspace_config['region'],
629
+ profile=workspace_config['profile'],
630
+ max_retries=DEFAULT_MAX_RETRIES,
631
+ retry_delay=DEFAULT_RETRY_DELAY,
632
+ service_name=DEFAULT_SERVICE_NAME,
633
+ )
544
634
  except Exception as e:
545
635
  error_msg = f'Error executing range query: {str(e)}'
546
636
  logger.error(error_msg)
@@ -549,28 +639,55 @@ async def execute_range_query(
549
639
 
550
640
 
551
641
  @mcp.tool(name='ListMetrics')
552
- async def list_metrics(ctx: Context) -> MetricsList:
642
+ async def list_metrics(
643
+ ctx: Context,
644
+ workspace_id: Optional[str] = Field(
645
+ None,
646
+ description='The Prometheus workspace ID to use (e.g., ws-12345678-abcd-1234-efgh-123456789012). Optional if a URL is configured via command line arguments.',
647
+ ),
648
+ region: Optional[str] = Field(None, description='AWS region (defaults to current region)'),
649
+ profile: Optional[str] = Field(None, description='AWS profile to use (defaults to None)'),
650
+ ) -> MetricsList:
553
651
  """Get a list of all metric names.
554
652
 
555
653
  ## Usage
556
654
  - Use this tool to discover available metrics in the Prometheus server
557
655
  - Returns a sorted list of all metric names
558
656
  - Useful for exploration before crafting specific queries
657
+ - If workspace_id is not known, use GetAvailableWorkspaces tool first to find available workspaces and ASK THE USER to choose one
559
658
 
560
659
  ## Example
561
- ```
562
- metrics_response = await list_metrics()
563
- print('Available metrics:', metrics_response.metrics[:10]) # Show first 10 metrics
564
- ```
660
+ Input:
661
+ workspace_id: "ws-12345678-abcd-1234-efgh-123456789012"
662
+ region: "us-east-1"
663
+
664
+ Output:
665
+ {
666
+ "metrics": [
667
+ "go_gc_duration_seconds",
668
+ "go_goroutines",
669
+ "http_requests_total",
670
+ ...
671
+ ]
672
+ }
565
673
  """
566
674
  try:
675
+ # Configure workspace using the provided workspace_id
676
+ workspace_config = await configure_workspace_for_request(
677
+ ctx, workspace_id, region, profile
678
+ )
679
+
567
680
  logger.info('Listing all available metrics')
568
- max_retries = 3 # Default value
569
- if config and hasattr(config, 'max_retries') and config.max_retries is not None:
570
- max_retries = config.max_retries
571
681
 
572
- data = await make_prometheus_request(
573
- 'label/__name__/values', params={}, max_retries=max_retries
682
+ data = await PrometheusClient.make_request(
683
+ prometheus_url=workspace_config['prometheus_url'],
684
+ endpoint='label/__name__/values',
685
+ params={},
686
+ region=workspace_config['region'],
687
+ profile=workspace_config['profile'],
688
+ max_retries=DEFAULT_MAX_RETRIES,
689
+ retry_delay=DEFAULT_RETRY_DELAY,
690
+ service_name=DEFAULT_SERVICE_NAME,
574
691
  )
575
692
  return MetricsList(metrics=sorted(data))
576
693
  except Exception as e:
@@ -581,35 +698,51 @@ async def list_metrics(ctx: Context) -> MetricsList:
581
698
 
582
699
 
583
700
  @mcp.tool(name='GetServerInfo')
584
- async def get_server_info(ctx: Context) -> ServerInfo:
701
+ async def get_server_info(
702
+ ctx: Context,
703
+ workspace_id: Optional[str] = Field(
704
+ None,
705
+ description='The Prometheus workspace ID to use (e.g., ws-12345678-abcd-1234-efgh-123456789012). Optional if a URL is configured via command line arguments.',
706
+ ),
707
+ region: Optional[str] = Field(None, description='AWS region (defaults to current region)'),
708
+ profile: Optional[str] = Field(None, description='AWS profile to use (defaults to None)'),
709
+ ) -> ServerInfo:
585
710
  """Get information about the Prometheus server configuration.
586
711
 
587
712
  ## Usage
588
713
  - Use this tool to retrieve the current server configuration
589
714
  - Returns details about the Prometheus URL, AWS region, profile, and service name
590
715
  - Useful for debugging connection issues
716
+ - If workspace_id is not known, use GetAvailableWorkspaces tool first to find available workspaces and ASK THE USER to choose one
717
+ - Uses DescribeWorkspace API to get the exact workspace URL
718
+ - No manual URL construction is performed
591
719
 
592
720
  ## Example
593
- ```
594
- info = await get_server_info()
595
- print(f'Connected to Prometheus at {info.prometheus_url} in region {info.aws_region}')
596
- ```
721
+ Input:
722
+ workspace_id: "ws-12345678-abcd-1234-efgh-123456789012"
723
+ region: "us-east-1"
724
+
725
+ Output:
726
+ {
727
+ "prometheus_url": "https://aps-workspaces.us-east-1.amazonaws.com/workspaces/ws-12345678-abcd-1234-efgh-123456789012",
728
+ "aws_region": "us-east-1",
729
+ "aws_profile": "default",
730
+ "service_name": "aps"
731
+ }
597
732
  """
598
733
  try:
734
+ # Configure workspace using the provided workspace_id
735
+ workspace_config = await configure_workspace_for_request(
736
+ ctx, workspace_id, region, profile
737
+ )
738
+
599
739
  logger.info('Retrieving server configuration information')
600
- if not config:
601
- return ServerInfo(
602
- prometheus_url='Not configured',
603
- aws_region='Not configured',
604
- aws_profile='Not configured',
605
- service_name='Not configured',
606
- )
607
740
 
608
741
  return ServerInfo(
609
- prometheus_url=config.prometheus_url or 'Not configured',
610
- aws_region=config.aws_region or 'Not configured',
611
- aws_profile=config.aws_profile or 'default',
612
- service_name=config.service_name or DEFAULT_SERVICE_NAME,
742
+ prometheus_url=workspace_config['prometheus_url'],
743
+ aws_region=workspace_config['region'],
744
+ aws_profile=workspace_config['profile'] or 'default',
745
+ service_name=DEFAULT_SERVICE_NAME,
613
746
  )
614
747
  except Exception as e:
615
748
  error_msg = f'Error retrieving server info: {str(e)}'
@@ -618,14 +751,302 @@ async def get_server_info(ctx: Context) -> ServerInfo:
618
751
  raise
619
752
 
620
753
 
754
+ @mcp.tool(name='GetAvailableWorkspaces')
755
+ async def get_available_workspaces(
756
+ ctx: Context,
757
+ region: Optional[str] = Field(None, description='AWS region (defaults to current region)'),
758
+ profile: Optional[str] = Field(None, description='AWS profile to use (defaults to None)'),
759
+ ) -> Dict[str, Any]:
760
+ """List all available Prometheus workspaces in the specified region.
761
+
762
+ ## Usage
763
+ - Use this tool to see all available Prometheus workspaces
764
+ - Shows workspace ID, alias, status, and URL for active workspaces
765
+ - IMPORTANT: When multiple workspaces are available, present them to the user and ask them to choose one
766
+ - DO NOT automatically select a workspace; always ask the user to choose when multiple options exist
767
+ - Uses DescribeWorkspace API to get the exact URL for each workspace
768
+ - No manual URL construction is performed
769
+
770
+ ## Example
771
+ Input:
772
+ region: "us-east-1"
773
+
774
+ Output:
775
+ {
776
+ "workspaces": [
777
+ {
778
+ "workspace_id": "ws-12345678-abcd-1234-efgh-123456789012",
779
+ "alias": "production",
780
+ "status": "ACTIVE",
781
+ "prometheus_url": "https://aps-workspaces.us-east-1.amazonaws.com/workspaces/ws-12345678-abcd-1234-efgh-123456789012",
782
+ "is_configured": true
783
+ },
784
+ {
785
+ "workspace_id": "ws-87654321-dcba-4321-hgfe-210987654321",
786
+ "alias": "development",
787
+ "status": "ACTIVE",
788
+ "prometheus_url": "https://aps-workspaces.us-east-1.amazonaws.com/workspaces/ws-87654321-dcba-4321-hgfe-210987654321",
789
+ "is_configured": false
790
+ }
791
+ ],
792
+ "count": 2,
793
+ "region": "us-east-1",
794
+ "requires_user_selection": true,
795
+ "configured_workspace_id": "ws-12345678-abcd-1234-efgh-123456789012"
796
+ }
797
+ """
798
+ try:
799
+ # Use provided region or default from environment
800
+ aws_region = region or os.getenv('AWS_REGION') or DEFAULT_AWS_REGION
801
+ aws_profile = profile or os.getenv(ENV_AWS_PROFILE)
802
+
803
+ # Check if we already have a URL configured and if it contains a workspace ID
804
+ prometheus_url = os.getenv('PROMETHEUS_URL')
805
+ configured_workspace_id = None
806
+ if prometheus_url:
807
+ configured_workspace_id = extract_workspace_id_from_url(prometheus_url)
808
+ if configured_workspace_id:
809
+ logger.info(f'Found configured workspace ID in URL: {configured_workspace_id}')
810
+
811
+ logger.info(f'Listing available Prometheus workspaces in region {aws_region}')
812
+
813
+ # Get a fresh client for this request
814
+ aps_client = get_prometheus_client(region_name=aws_region, profile_name=aws_profile)
815
+ response = aps_client.list_workspaces()
816
+
817
+ workspaces = []
818
+ configured_workspace_details = None
819
+
820
+ for ws in response.get('workspaces', []):
821
+ workspace_id = ws['workspaceId']
822
+
823
+ # Only get details for active workspaces
824
+ if ws['status']['statusCode'] == 'ACTIVE':
825
+ try:
826
+ # Get full details including URL from DescribeWorkspace API
827
+ details = await get_workspace_details(workspace_id, aws_region, aws_profile)
828
+
829
+ # If this is the configured workspace, mark it
830
+ if configured_workspace_id and workspace_id == configured_workspace_id:
831
+ details['is_configured'] = True
832
+ details['note'] = '(Detected from URL - will be used automatically)'
833
+ configured_workspace_details = details
834
+ else:
835
+ details['is_configured'] = False
836
+
837
+ workspaces.append(details)
838
+ except Exception as e:
839
+ logger.warning(f'Could not get details for workspace {workspace_id}: {str(e)}')
840
+ # Skip this workspace if we can't get its details
841
+ continue
842
+ else:
843
+ # For non-active workspaces, just include basic info without URL
844
+ workspaces.append(
845
+ {
846
+ 'workspace_id': workspace_id,
847
+ 'alias': ws.get('alias', 'No alias'),
848
+ 'status': ws['status']['statusCode'],
849
+ 'region': aws_region,
850
+ 'is_configured': configured_workspace_id
851
+ and workspace_id == configured_workspace_id,
852
+ }
853
+ )
854
+
855
+ # If we have a configured workspace but it wasn't found in the list,
856
+ # it might be in a different region. Add it to the list if we have details.
857
+ if configured_workspace_id and not configured_workspace_details and prometheus_url:
858
+ try:
859
+ # Create a basic entry for the configured workspace
860
+ workspaces.append(
861
+ {
862
+ 'workspace_id': configured_workspace_id,
863
+ 'alias': 'Configured Workspace',
864
+ 'status': 'ACTIVE', # Assume active since we have a URL
865
+ 'prometheus_url': prometheus_url,
866
+ 'region': aws_region,
867
+ 'is_configured': True,
868
+ 'note': '(Detected from URL - will be used automatically)',
869
+ }
870
+ )
871
+ logger.info(f'Added configured workspace {configured_workspace_id} from URL')
872
+ except Exception as e:
873
+ logger.warning(f'Could not add configured workspace: {str(e)}')
874
+
875
+ # Sort workspaces to put configured workspace first
876
+ workspaces.sort(key=lambda ws: 0 if ws.get('is_configured') else 1)
877
+
878
+ logger.info(f'Found {len(workspaces)} workspaces in region {aws_region}')
879
+
880
+ # If we have a configured workspace ID from the URL, we don't need user selection
881
+ requires_selection = not configured_workspace_id and len(workspaces) > 1
882
+
883
+ message = ''
884
+ if configured_workspace_id:
885
+ message = f'A workspace ID ({configured_workspace_id}) was detected in the URL and will be used automatically. You can override it by explicitly providing a workspace_id parameter.'
886
+ elif len(workspaces) > 1:
887
+ message = 'Please choose a workspace ID to use with your queries.'
888
+ else:
889
+ message = 'Only one workspace is available. You can use it by specifying its workspace_id in your queries.'
890
+
891
+ return {
892
+ 'workspaces': workspaces,
893
+ 'count': len(workspaces),
894
+ 'region': aws_region,
895
+ 'requires_user_selection': requires_selection,
896
+ 'configured_workspace_id': configured_workspace_id,
897
+ 'message': message,
898
+ }
899
+ except Exception as e:
900
+ error_msg = f'Error listing workspaces: {str(e)}'
901
+ logger.error(error_msg)
902
+ await ctx.error(error_msg)
903
+ raise
904
+
905
+
906
+ def extract_workspace_id_from_url(url: str) -> Optional[str]:
907
+ """Extract workspace ID from a Prometheus URL.
908
+
909
+ Args:
910
+ url: The Prometheus URL that may contain a workspace ID
911
+
912
+ Returns:
913
+ The extracted workspace ID or None if not found
914
+ """
915
+ if not url:
916
+ return None
917
+
918
+ # Look for the pattern /workspaces/ws-XXXX in the URL
919
+ import re
920
+
921
+ match = re.search(r'/workspaces/(ws-[\w-]+)', url)
922
+ if match:
923
+ workspace_id = match.group(1)
924
+ logger.info(f'Extracted workspace ID from URL: {workspace_id}')
925
+ return workspace_id
926
+ return None
927
+
928
+
929
+ async def configure_workspace_for_request(
930
+ ctx: Context,
931
+ workspace_id: Optional[str] = None,
932
+ region: Optional[str] = None,
933
+ profile: Optional[str] = None,
934
+ ) -> Dict[str, Any]:
935
+ """Configure the workspace for the current request.
936
+
937
+ If a URL is provided via environment variable, it will be used directly.
938
+ If a workspace ID is provided, it will be used to fetch the URL from AWS API.
939
+ If no workspace ID is provided but the URL contains one, it will be extracted and used.
940
+
941
+ Args:
942
+ ctx: The MCP context
943
+ workspace_id: The Prometheus workspace ID to use (optional if URL contains workspace ID)
944
+ region: Optional AWS region (defaults to current region)
945
+ profile: Optional AWS profile to use
946
+
947
+ Returns:
948
+ Dictionary with workspace configuration including the URL
949
+ """
950
+ try:
951
+ # Use provided region or default from environment
952
+ aws_region = region or os.getenv('AWS_REGION') or DEFAULT_AWS_REGION
953
+ aws_profile = profile or os.getenv(ENV_AWS_PROFILE)
954
+
955
+ # Check if we have a URL from environment
956
+ prometheus_url = os.getenv('PROMETHEUS_URL')
957
+
958
+ # If no workspace_id is provided, extract it from the URL if possible
959
+ if not workspace_id and prometheus_url:
960
+ extracted_workspace_id = extract_workspace_id_from_url(prometheus_url)
961
+ if extracted_workspace_id:
962
+ workspace_id = extracted_workspace_id
963
+ logger.info(f'Using workspace ID extracted from URL: {workspace_id}')
964
+
965
+ # If we have a URL but no workspace_id could be extracted, use the URL directly
966
+ if prometheus_url:
967
+ logger.info(f'Using Prometheus URL from environment: {prometheus_url}')
968
+
969
+ # Test connection with the URL
970
+ if not await PrometheusConnection.test_connection(
971
+ prometheus_url, aws_region, aws_profile
972
+ ):
973
+ error_msg = f'Failed to connect to Prometheus with configured URL {prometheus_url}'
974
+ logger.error(error_msg)
975
+ await ctx.error(error_msg)
976
+ raise RuntimeError(error_msg)
977
+
978
+ return {
979
+ 'prometheus_url': prometheus_url,
980
+ 'region': aws_region,
981
+ 'profile': aws_profile,
982
+ 'workspace_id': workspace_id,
983
+ }
984
+
985
+ # If no URL is configured, require workspace_id
986
+ if not workspace_id:
987
+ error_msg = 'Workspace ID is required when no Prometheus URL is configured. Please use GetAvailableWorkspaces to list available workspaces and choose one.'
988
+ logger.error(error_msg)
989
+ await ctx.error(error_msg)
990
+ raise ValueError(error_msg)
991
+
992
+ logger.info(f'Configuring workspace ID for request: {workspace_id}')
993
+
994
+ # Validate workspace ID format
995
+ if not workspace_id.startswith('ws-'):
996
+ logger.warning(
997
+ f'Workspace ID "{workspace_id}" does not start with "ws-", which is unusual'
998
+ )
999
+
1000
+ # Get workspace details from DescribeWorkspace API
1001
+ workspace_details = await get_workspace_details(workspace_id, aws_region, aws_profile)
1002
+ prometheus_url = workspace_details['prometheus_url']
1003
+ logger.info(f'Using Prometheus URL from DescribeWorkspace API: {prometheus_url}')
1004
+
1005
+ # Test connection with the URL
1006
+ if not await PrometheusConnection.test_connection(prometheus_url, aws_region, aws_profile):
1007
+ error_msg = f'Failed to connect to Prometheus with workspace ID {workspace_id}'
1008
+ logger.error(error_msg)
1009
+ await ctx.error(error_msg)
1010
+ raise RuntimeError(error_msg)
1011
+
1012
+ logger.info(f'Successfully configured workspace {workspace_id} for request')
1013
+
1014
+ # Return workspace configuration
1015
+ return {
1016
+ 'prometheus_url': prometheus_url,
1017
+ 'region': aws_region,
1018
+ 'profile': aws_profile,
1019
+ 'workspace_id': workspace_id,
1020
+ }
1021
+ except Exception as e:
1022
+ error_msg = f'Error configuring workspace: {str(e)}'
1023
+ logger.error(error_msg)
1024
+ await ctx.error(error_msg)
1025
+ raise
1026
+
1027
+
621
1028
  async def async_main():
622
1029
  """Run the async initialization tasks."""
623
- # Test connection
624
- if not await test_prometheus_connection():
625
- logger.error('Prometheus connection test failed')
626
- sys.exit(1)
1030
+ # Check if URL is configured in environment
1031
+ prometheus_url = os.getenv('PROMETHEUS_URL')
1032
+ if prometheus_url:
1033
+ logger.info(f'Using Prometheus URL from environment: {prometheus_url}')
1034
+
1035
+ # Check if the URL contains a workspace ID
1036
+ workspace_id = extract_workspace_id_from_url(prometheus_url)
1037
+ if workspace_id:
1038
+ logger.info(f'Detected workspace ID in URL: {workspace_id}')
1039
+ logger.info(
1040
+ 'This workspace ID can be used with queries, but must be explicitly provided'
1041
+ )
1042
+ else:
1043
+ logger.info('No workspace ID detected in URL')
627
1044
 
628
- logger.info('Prometheus connection successful')
1045
+ logger.info('Workspace ID will be required for each tool invocation')
1046
+ else:
1047
+ logger.info(
1048
+ 'Initializing Prometheus MCP Server - workspace ID will be required for each tool invocation'
1049
+ )
629
1050
 
630
1051
 
631
1052
  def main():
@@ -633,25 +1054,36 @@ def main():
633
1054
  logger.info('Starting Prometheus MCP Server...')
634
1055
 
635
1056
  # Parse arguments
636
- args = parse_arguments()
637
-
638
- # Load configuration
639
- config_data = load_config(args)
640
-
641
- # Create config object
642
- global config
643
- config = PrometheusConfig(
644
- prometheus_url=config_data['prometheus_url'],
645
- aws_region=config_data['aws_region'],
646
- aws_profile=config_data['aws_profile'],
647
- service_name=config_data['service_name'],
648
- retry_delay=config_data['retry_delay'],
649
- max_retries=config_data['max_retries'],
650
- )
651
-
652
- # Setup environment
653
- if not setup_environment(config_data):
654
- logger.error('Environment setup failed')
1057
+ args = ConfigManager.parse_arguments()
1058
+
1059
+ # Setup basic configuration
1060
+ config = ConfigManager.setup_basic_config(args)
1061
+
1062
+ # Set as environment variables for other functions to use
1063
+ if config['url']:
1064
+ os.environ['PROMETHEUS_URL'] = config['url']
1065
+ if config['region']:
1066
+ os.environ['AWS_REGION'] = config['region']
1067
+ if config['profile']:
1068
+ os.environ[ENV_AWS_PROFILE] = config['profile']
1069
+
1070
+ if config['url']:
1071
+ logger.info(f'Using configured Prometheus URL: {config["url"]}')
1072
+
1073
+ # Check if the URL contains a workspace ID
1074
+ workspace_id = extract_workspace_id_from_url(config['url'])
1075
+ if workspace_id:
1076
+ logger.info(f'Detected workspace ID in URL: {workspace_id}')
1077
+ logger.info(
1078
+ 'This workspace will be used automatically when no workspace ID is provided'
1079
+ )
1080
+ else:
1081
+ logger.info('No workspace ID detected in URL')
1082
+ logger.info('Workspace ID will be required for each tool invocation')
1083
+
1084
+ # Validate AWS credentials
1085
+ if not AWSCredentials.validate(config['region'], config['profile']):
1086
+ logger.error('AWS credentials validation failed')
655
1087
  sys.exit(1)
656
1088
 
657
1089
  # Run async initialization in an event loop
@@ -660,8 +1092,14 @@ def main():
660
1092
  asyncio.run(async_main())
661
1093
 
662
1094
  logger.info('Starting server...')
1095
+
663
1096
  # Run with stdio transport
664
- mcp.run(transport='stdio')
1097
+ try:
1098
+ logger.info('Starting with stdio transport...')
1099
+ mcp.run(transport='stdio')
1100
+ except Exception as e:
1101
+ logger.error(f'Error starting server with stdio transport: {e}')
1102
+ sys.exit(1)
665
1103
 
666
1104
 
667
1105
  if __name__ == '__main__':