Skip to content

Commit bb71b9f

Browse files
committed
fix: don't print to stderr when running as MCP server via uvx to avoid JSON parsing errors
1 parent 928baff commit bb71b9f

File tree

1 file changed

+91
-53
lines changed

1 file changed

+91
-53
lines changed

src/alertmanager_mcp_server/server.py

Lines changed: 91 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#!/usr/bin/env python
22
import os
3+
import logging
4+
import sys
35
from contextvars import ContextVar
46
from dataclasses import dataclass
57
from typing import Any, Dict, Optional, List
@@ -15,25 +17,47 @@
1517
import requests
1618
import 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+
1841
dotenv.load_dotenv()
1942
mcp = 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

2751
def 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

5377
def 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"))
90115
MAX_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"))
92118
MAX_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

97125
def 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

521554
def 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("\nMulti-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("\nMulti-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("\nStarting Prometheus Alertmanager MCP Server...")
755+
safe_print("\nStarting 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

Comments
 (0)