11#!/usr/bin/env python
22import os
3+ import logging
4+ import sys
35from contextvars import ContextVar
46from dataclasses import dataclass
57from typing import Any , Dict , Optional , List
1517import requests
1618import uvicorn
1719
20+ logging .basicConfig (
21+ level = logging .INFO ,
22+ format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
23+ )
24+ logger = logging .getLogger (__name__ )
25+
26+
27+ def safe_print (text ):
28+ # Don't print to stderr when running as MCP server via uvx to avoid JSON parsing errors
29+ # Check if we're running as MCP server (no TTY and uvx in process name)
30+ if not sys .stderr .isatty ():
31+ # Running as MCP server, suppress output to avoid JSON parsing errors
32+ logger .debug (f"[MCP Server] { text } " )
33+ return
34+
35+ try :
36+ print (text , file = sys .stderr )
37+ except UnicodeEncodeError :
38+ print (text .encode ('ascii' , errors = 'replace' ).decode (), file = sys .stderr )
39+
40+
1841dotenv .load_dotenv ()
1942mcp = FastMCP ("Alertmanager MCP" )
2043
2144# ContextVar for per-request X-Scope-OrgId header
2245# Used for multi-tenant Alertmanager setups (e.g., Mimir)
2346# ContextVar ensures proper isolation per async context/task
24- _current_scope_org_id : ContextVar [Optional [str ]] = ContextVar ("current_scope_org_id" , default = None )
47+ _current_scope_org_id : ContextVar [Optional [str ]] = ContextVar (
48+ "current_scope_org_id" , default = None )
2549
2650
2751def extract_header_from_scope (scope : dict , header_name : str ) -> Optional [str ]:
2852 """Extract a header value from an ASGI scope.
29-
53+
3054 Parameters
3155 ----------
3256 scope : dict
3357 ASGI scope dictionary containing headers
3458 header_name : str
3559 Header name to extract (should be lowercase, e.g. "x-scope-orgid")
36-
60+
3761 Returns
3862 -------
3963 Optional[str]
@@ -52,14 +76,14 @@ def extract_header_from_scope(scope: dict, header_name: str) -> Optional[str]:
5276
5377def extract_header_from_request (request : Request , header_name : str ) -> Optional [str ]:
5478 """Extract a header value from a Starlette Request.
55-
79+
5680 Parameters
5781 ----------
5882 request : Request
5983 Starlette request object
6084 header_name : str
6185 Header name to extract (case-insensitive)
62-
86+
6387 Returns
6488 -------
6589 Optional[str]
@@ -86,50 +110,54 @@ class AlertmanagerConfig:
86110)
87111
88112# Pagination defaults and limits (configurable via environment variables)
89- DEFAULT_SILENCE_PAGE = int (os .environ .get ("ALERTMANAGER_DEFAULT_SILENCE_PAGE" , "10" ))
113+ DEFAULT_SILENCE_PAGE = int (os .environ .get (
114+ "ALERTMANAGER_DEFAULT_SILENCE_PAGE" , "10" ))
90115MAX_SILENCE_PAGE = int (os .environ .get ("ALERTMANAGER_MAX_SILENCE_PAGE" , "50" ))
91- DEFAULT_ALERT_PAGE = int (os .environ .get ("ALERTMANAGER_DEFAULT_ALERT_PAGE" , "10" ))
116+ DEFAULT_ALERT_PAGE = int (os .environ .get (
117+ "ALERTMANAGER_DEFAULT_ALERT_PAGE" , "10" ))
92118MAX_ALERT_PAGE = int (os .environ .get ("ALERTMANAGER_MAX_ALERT_PAGE" , "25" ))
93- DEFAULT_ALERT_GROUP_PAGE = int (os .environ .get ("ALERTMANAGER_DEFAULT_ALERT_GROUP_PAGE" , "3" ))
94- MAX_ALERT_GROUP_PAGE = int (os .environ .get ("ALERTMANAGER_MAX_ALERT_GROUP_PAGE" , "5" ))
119+ DEFAULT_ALERT_GROUP_PAGE = int (os .environ .get (
120+ "ALERTMANAGER_DEFAULT_ALERT_GROUP_PAGE" , "3" ))
121+ MAX_ALERT_GROUP_PAGE = int (os .environ .get (
122+ "ALERTMANAGER_MAX_ALERT_GROUP_PAGE" , "5" ))
95123
96124
97125def url_join (base : str , path : str ) -> str :
98126 """Join a base URL with a path, preserving the base URL's path component.
99-
127+
100128 Unlike urllib.parse.urljoin, this function preserves the path in the base URL
101129 when the path argument starts with '/'. This is useful for APIs hosted at
102130 subpaths (e.g., http://localhost:8080/alertmanager).
103-
131+
104132 Examples
105133 --------
106134 >>> url_join("http://localhost:8080/alertmanager", "/api/v2/alerts")
107135 'http://localhost:8080/alertmanager/api/v2/alerts'
108-
136+
109137 >>> url_join("http://localhost:8080/alertmanager/", "/api/v2/alerts")
110138 'http://localhost:8080/alertmanager/api/v2/alerts'
111-
139+
112140 >>> url_join("http://localhost:8080", "/api/v2/alerts")
113141 'http://localhost:8080/api/v2/alerts'
114-
142+
115143 Parameters
116144 ----------
117145 base : str
118146 The base URL which may include a path component
119147 path : str
120148 The path to append, which may or may not start with '/'
121-
149+
122150 Returns
123151 -------
124152 str
125153 The combined URL with both base path and appended path
126154 """
127155 # Remove trailing slash from base if present
128156 base = base .rstrip ('/' )
129-
157+
130158 # Remove leading slash from path if present
131159 path = path .lstrip ('/' )
132-
160+
133161 # Combine with a single slash
134162 return f"{ base } /{ path } "
135163
@@ -161,24 +189,24 @@ def make_request(method="GET", route="/", **kwargs):
161189 if config .username and config .password
162190 else None
163191 )
164-
192+
165193 # Add X-Scope-OrgId header for multi-tenant setups
166194 # Priority: 1) Request header from caller (via ContextVar), 2) Static config tenant
167195 headers = kwargs .get ("headers" , {})
168-
196+
169197 tenant_id = _current_scope_org_id .get () or config .tenant_id
170-
198+
171199 if tenant_id :
172200 headers ["X-Scope-OrgId" ] = tenant_id
173201 if headers :
174202 kwargs ["headers" ] = headers
175-
203+
176204 response = requests .request (
177205 method = method .upper (), url = route , auth = auth , timeout = 60 , ** kwargs
178206 )
179207 response .raise_for_status ()
180208 result = response .json ()
181-
209+
182210 # Ensure we always return something (empty list is valid but might cause issues)
183211 if result is None :
184212 return {"message" : "No data returned" }
@@ -311,7 +339,8 @@ async def get_silences(filter: Optional[str] = None,
311339 Use the 'has_more' flag to determine if additional pages are available.
312340 """
313341 # Validate pagination parameters
314- count , offset , error = validate_pagination_params (count , offset , MAX_SILENCE_PAGE )
342+ count , offset , error = validate_pagination_params (
343+ count , offset , MAX_SILENCE_PAGE )
315344 if error :
316345 return {"error" : error }
317346
@@ -320,7 +349,8 @@ async def get_silences(filter: Optional[str] = None,
320349 params = {"filter" : filter }
321350
322351 # Get all silences from the API
323- all_silences = make_request (method = "GET" , route = "/api/v2/silences" , params = params )
352+ all_silences = make_request (
353+ method = "GET" , route = "/api/v2/silences" , params = params )
324354
325355 # Apply pagination and return results
326356 return paginate_results (all_silences , count , offset )
@@ -420,7 +450,8 @@ async def get_alerts(filter: Optional[str] = None,
420450 Use the 'has_more' flag to determine if additional pages are available.
421451 """
422452 # Validate pagination parameters
423- count , offset , error = validate_pagination_params (count , offset , MAX_ALERT_PAGE )
453+ count , offset , error = validate_pagination_params (
454+ count , offset , MAX_ALERT_PAGE )
424455 if error :
425456 return {"error" : error }
426457
@@ -435,7 +466,8 @@ async def get_alerts(filter: Optional[str] = None,
435466 params ["active" ] = active
436467
437468 # Get all alerts from the API
438- all_alerts = make_request (method = "GET" , route = "/api/v2/alerts" , params = params )
469+ all_alerts = make_request (
470+ method = "GET" , route = "/api/v2/alerts" , params = params )
439471
440472 # Apply pagination and return results
441473 return paginate_results (all_alerts , count , offset )
@@ -498,7 +530,8 @@ async def get_alert_groups(silenced: Optional[bool] = None,
498530 Use the 'has_more' flag to determine if additional pages are available.
499531 """
500532 # Validate pagination parameters
501- count , offset , error = validate_pagination_params (count , offset , MAX_ALERT_GROUP_PAGE )
533+ count , offset , error = validate_pagination_params (
534+ count , offset , MAX_ALERT_GROUP_PAGE )
502535 if error :
503536 return {"error" : error }
504537
@@ -520,32 +553,35 @@ async def get_alert_groups(silenced: Optional[bool] = None,
520553
521554def setup_environment ():
522555 if dotenv .load_dotenv ():
523- print ("Loaded environment variables from .env file" )
556+ safe_print ("Loaded environment variables from .env file" )
524557 else :
525- print ("No .env file found or could not load it - using environment variables" )
558+ safe_print (
559+ "No .env file found or could not load it - using environment variables" )
526560
527561 if not config .url :
528- print ("ERROR: ALERTMANAGER_URL environment variable is not set" )
529- print ("Please set it to your Alertmanager server URL" )
530- print ("Example: http://your-alertmanager:9093" )
562+ safe_print ("ERROR: ALERTMANAGER_URL environment variable is not set" )
563+ safe_print ("Please set it to your Alertmanager server URL" )
564+ safe_print ("Example: http://your-alertmanager:9093" )
531565 return False
532566
533- print ("Alertmanager configuration:" )
534- print (f" Server URL: { config .url } " )
567+ safe_print ("Alertmanager configuration:" )
568+ safe_print (f" Server URL: { config .url } " )
535569
536570 if config .username and config .password :
537- print (" Authentication: Using basic auth" )
571+ safe_print (" Authentication: Using basic auth" )
538572 else :
539- print (" Authentication: None (no credentials provided)" )
540-
573+ safe_print (" Authentication: None (no credentials provided)" )
574+
541575 if config .tenant_id :
542- print (f" Static Tenant ID: { config .tenant_id } " )
576+ safe_print (f" Static Tenant ID: { config .tenant_id } " )
543577 else :
544- print (" Static Tenant ID: None" )
545-
546- print ("\n Multi-tenant Support:" )
547- print (" - Send X-Scope-OrgId header with requests for multi-tenant setups" )
548- print (" - Request header takes precedence over static ALERTMANAGER_TENANT config" )
578+ safe_print (" Static Tenant ID: None" )
579+
580+ safe_print ("\n Multi-tenant Support:" )
581+ safe_print (
582+ " - Send X-Scope-OrgId header with requests for multi-tenant setups" )
583+ safe_print (
584+ " - Request header takes precedence over static ALERTMANAGER_TENANT config" )
549585
550586 return True
551587
@@ -576,8 +612,9 @@ async def handle_sse(request: Request) -> None:
576612 """
577613 # Extract X-Scope-OrgId header if present and set in ContextVar
578614 scope_org_id = extract_header_from_request (request , "x-scope-orgid" )
579- token = _current_scope_org_id .set (scope_org_id ) if scope_org_id else None
580-
615+ token = _current_scope_org_id .set (
616+ scope_org_id ) if scope_org_id else None
617+
581618 try :
582619 # Connect the SSE transport to the request
583620 async with sse .connect_sse (
@@ -616,17 +653,17 @@ def create_streamable_app(mcp_server: Server, *, debug: bool = False) -> Starlet
616653 at the '/mcp' path for GET/POST/DELETE requests.
617654 """
618655 transport = StreamableHTTPServerTransport (None )
619-
656+
620657 async def handle_mcp_request (scope , receive , send ):
621658 """Wrapper to extract X-Scope-OrgId header before handling MCP request."""
622659 token = None
623-
660+
624661 if scope ['type' ] == 'http' :
625662 # Extract X-Scope-OrgId from headers
626663 scope_org_id = extract_header_from_scope (scope , "x-scope-orgid" )
627664 if scope_org_id :
628665 token = _current_scope_org_id .set (scope_org_id )
629-
666+
630667 try :
631668 # Pass to the actual transport handler
632669 await transport .handle_request (scope , receive , send )
@@ -701,7 +738,8 @@ def run_server():
701738 try :
702739 port_default = int (env_port ) if env_port is not None else 8000
703740 except (TypeError , ValueError ):
704- print (f"Invalid MCP_PORT value '{ env_port } ', falling back to 8000" )
741+ safe_print (
742+ f"Invalid MCP_PORT value '{ env_port } ', falling back to 8000" )
705743 port_default = 8000
706744
707745 # Allow choosing between stdio and SSE transport modes
@@ -714,24 +752,24 @@ def run_server():
714752 parser .add_argument ('--port' , type = int , default = port_default ,
715753 help = 'Port to listen on (for SSE mode) — can also be set via $MCP_PORT' )
716754 args = parser .parse_args ()
717- print ("\n Starting Prometheus Alertmanager MCP Server..." )
755+ safe_print ("\n Starting Prometheus Alertmanager MCP Server..." )
718756
719757 # Launch the server with the selected transport mode
720758 if args .transport == 'sse' :
721- print ("Running server with SSE transport (web-based)" )
759+ safe_print ("Running server with SSE transport (web-based)" )
722760 # Run with SSE transport (web-based)
723761 # Create a Starlette app to serve the MCP server
724762 starlette_app = create_starlette_app (mcp_server , debug = True )
725763 # Start the web server with the configured host and port
726764 uvicorn .run (starlette_app , host = args .host , port = args .port )
727765 elif args .transport == 'http' :
728- print ("Running server with http transport (streamable HTTP)" )
766+ safe_print ("Running server with http transport (streamable HTTP)" )
729767 # Run with streamable-http transport served by uvicorn so host/port
730768 # CLI/env variables control the listening socket (same pattern as SSE).
731769 starlette_app = create_streamable_app (mcp_server , debug = True )
732770 uvicorn .run (starlette_app , host = args .host , port = args .port )
733771 else :
734- print ("Running server with stdio transport (default)" )
772+ safe_print ("Running server with stdio transport (default)" )
735773 # Run with stdio transport (default)
736774 # This mode communicates through standard input/output
737775 mcp .run (transport = 'stdio' )
0 commit comments