11#!/usr/bin/env python
22import os
3+ from contextvars import ContextVar
34from dataclasses import dataclass
45from typing import Any , Dict , Optional , List
56
67from mcp .server import Server
78from mcp .server .fastmcp import FastMCP
89from mcp .server .sse import SseServerTransport
910from mcp .server .streamable_http import StreamableHTTPServerTransport
10- from requests .compat import urljoin
1111from starlette .applications import Starlette
1212from starlette .requests import Request
1313from starlette .routing import Mount , Route
1818dotenv .load_dotenv ()
1919mcp = FastMCP ("Alertmanager MCP" )
2020
21+ # ContextVar for per-request X-Scope-OrgId header
22+ # Used for multi-tenant Alertmanager setups (e.g., Mimir)
23+ # ContextVar ensures proper isolation per async context/task
24+ _current_scope_org_id : ContextVar [Optional [str ]] = ContextVar ("current_scope_org_id" , default = None )
25+
26+
27+ def extract_header_from_scope (scope : dict , header_name : str ) -> Optional [str ]:
28+ """Extract a header value from an ASGI scope.
29+
30+ Parameters
31+ ----------
32+ scope : dict
33+ ASGI scope dictionary containing headers
34+ header_name : str
35+ Header name to extract (should be lowercase, e.g. "x-scope-orgid")
36+
37+ Returns
38+ -------
39+ Optional[str]
40+ The header value if found, None otherwise
41+ """
42+ headers = scope .get ("headers" , [])
43+ target = header_name .encode ("latin-1" )
44+ for name_bytes , value_bytes in headers :
45+ if name_bytes .lower () == target :
46+ try :
47+ return value_bytes .decode ("latin-1" )
48+ except Exception :
49+ return None
50+ return None
51+
52+
53+ def extract_header_from_request (request : Request , header_name : str ) -> Optional [str ]:
54+ """Extract a header value from a Starlette Request.
55+
56+ Parameters
57+ ----------
58+ request : Request
59+ Starlette request object
60+ header_name : str
61+ Header name to extract (case-insensitive)
62+
63+ Returns
64+ -------
65+ Optional[str]
66+ The header value if found, None otherwise
67+ """
68+ return request .headers .get (header_name )
69+
2170
2271@dataclass
2372class AlertmanagerConfig :
2473 url : str
2574 # Optional credentials
2675 username : Optional [str ] = None
2776 password : Optional [str ] = None
77+ # Optional tenant ID for multi-tenant setups
78+ tenant_id : Optional [str ] = None
2879
2980
3081config = AlertmanagerConfig (
3182 url = os .environ .get ("ALERTMANAGER_URL" , "" ),
3283 username = os .environ .get ("ALERTMANAGER_USERNAME" , "" ),
3384 password = os .environ .get ("ALERTMANAGER_PASSWORD" , "" ),
85+ tenant_id = os .environ .get ("ALERTMANAGER_TENANT" , "" ),
3486)
3587
3688# Pagination defaults and limits (configurable via environment variables)
@@ -42,6 +94,46 @@ class AlertmanagerConfig:
4294MAX_ALERT_GROUP_PAGE = int (os .environ .get ("ALERTMANAGER_MAX_ALERT_GROUP_PAGE" , "5" ))
4395
4496
97+ def url_join (base : str , path : str ) -> str :
98+ """Join a base URL with a path, preserving the base URL's path component.
99+
100+ Unlike urllib.parse.urljoin, this function preserves the path in the base URL
101+ when the path argument starts with '/'. This is useful for APIs hosted at
102+ subpaths (e.g., http://localhost:8080/alertmanager).
103+
104+ Examples
105+ --------
106+ >>> url_join("http://localhost:8080/alertmanager", "/api/v2/alerts")
107+ 'http://localhost:8080/alertmanager/api/v2/alerts'
108+
109+ >>> url_join("http://localhost:8080/alertmanager/", "/api/v2/alerts")
110+ 'http://localhost:8080/alertmanager/api/v2/alerts'
111+
112+ >>> url_join("http://localhost:8080", "/api/v2/alerts")
113+ 'http://localhost:8080/api/v2/alerts'
114+
115+ Parameters
116+ ----------
117+ base : str
118+ The base URL which may include a path component
119+ path : str
120+ The path to append, which may or may not start with '/'
121+
122+ Returns
123+ -------
124+ str
125+ The combined URL with both base path and appended path
126+ """
127+ # Remove trailing slash from base if present
128+ base = base .rstrip ('/' )
129+
130+ # Remove leading slash from path if present
131+ path = path .lstrip ('/' )
132+
133+ # Combine with a single slash
134+ return f"{ base } /{ path } "
135+
136+
45137def make_request (method = "GET" , route = "/" , ** kwargs ):
46138 """Make HTTP request and return a requests.Response object.
47139
@@ -62,17 +154,37 @@ def make_request(method="GET", route="/", **kwargs):
62154 The response from the Alertmanager API. This is a dictionary
63155 containing the response data.
64156 """
65- route = urljoin (config .url , route )
66- auth = (
67- requests .auth .HTTPBasicAuth (config .username , config .password )
68- if config .username and config .password
69- else None
70- )
71- response = requests .request (
72- method = method .upper (), url = route , auth = auth , timeout = 60 , ** kwargs
73- )
74- response .raise_for_status ()
75- return response .json ()
157+ try :
158+ route = url_join (config .url , route )
159+ auth = (
160+ requests .auth .HTTPBasicAuth (config .username , config .password )
161+ if config .username and config .password
162+ else None
163+ )
164+
165+ # Add X-Scope-OrgId header for multi-tenant setups
166+ # Priority: 1) Request header from caller (via ContextVar), 2) Static config tenant
167+ headers = kwargs .get ("headers" , {})
168+
169+ tenant_id = _current_scope_org_id .get () or config .tenant_id
170+
171+ if tenant_id :
172+ headers ["X-Scope-OrgId" ] = tenant_id
173+ if headers :
174+ kwargs ["headers" ] = headers
175+
176+ response = requests .request (
177+ method = method .upper (), url = route , auth = auth , timeout = 60 , ** kwargs
178+ )
179+ response .raise_for_status ()
180+ result = response .json ()
181+
182+ # Ensure we always return something (empty list is valid but might cause issues)
183+ if result is None :
184+ return {"message" : "No data returned" }
185+ return result
186+ except requests .exceptions .RequestException as e :
187+ return {"error" : str (e )}
76188
77189
78190def validate_pagination_params (count : int , offset : int , max_count : int ) -> tuple [int , int , Optional [str ]]:
@@ -251,7 +363,7 @@ async def get_silence(silence_id: str):
251363 dict:
252364 The Silence object from Alertmanager instance.
253365 """
254- return make_request (method = "GET" , route = urljoin ("/api/v2/silences/" , silence_id ))
366+ return make_request (method = "GET" , route = url_join ("/api/v2/silences/" , silence_id ))
255367
256368
257369@mcp .tool (description = "Delete a silence by its ID" )
@@ -269,7 +381,7 @@ async def delete_silence(silence_id: str):
269381 The response from the Alertmanager API.
270382 """
271383 return make_request (
272- method = "DELETE" , route = urljoin ("/api/v2/silences/" , silence_id )
384+ method = "DELETE" , route = url_join ("/api/v2/silences/" , silence_id )
273385 )
274386
275387
@@ -425,6 +537,15 @@ def setup_environment():
425537 print (" Authentication: Using basic auth" )
426538 else :
427539 print (" Authentication: None (no credentials provided)" )
540+
541+ if config .tenant_id :
542+ print (f" Static Tenant ID: { config .tenant_id } " )
543+ 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" )
428549
429550 return True
430551
@@ -453,18 +574,27 @@ async def handle_sse(request: Request) -> None:
453574 Args:
454575 request: The incoming HTTP request
455576 """
456- # Connect the SSE transport to the request
457- async with sse .connect_sse (
458- request .scope ,
459- request .receive ,
460- request ._send , # noqa: SLF001
461- ) as (read_stream , write_stream ):
462- # Run the MCP server with the SSE streams
463- await mcp_server .run (
464- read_stream ,
465- write_stream ,
466- mcp_server .create_initialization_options (),
467- )
577+ # Extract X-Scope-OrgId header if present and set in ContextVar
578+ 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+
581+ try :
582+ # Connect the SSE transport to the request
583+ async with sse .connect_sse (
584+ request .scope ,
585+ request .receive ,
586+ request ._send , # noqa: SLF001
587+ ) as (read_stream , write_stream ):
588+ # Run the MCP server with the SSE streams
589+ await mcp_server .run (
590+ read_stream ,
591+ write_stream ,
592+ mcp_server .create_initialization_options (),
593+ )
594+ finally :
595+ # Reset ContextVar to restore previous value
596+ if token is not None :
597+ _current_scope_org_id .reset (token )
468598
469599 # Create and return the Starlette application with routes
470600 return Starlette (
@@ -486,9 +616,27 @@ def create_streamable_app(mcp_server: Server, *, debug: bool = False) -> Starlet
486616 at the '/mcp' path for GET/POST/DELETE requests.
487617 """
488618 transport = StreamableHTTPServerTransport (None )
619+
620+ async def handle_mcp_request (scope , receive , send ):
621+ """Wrapper to extract X-Scope-OrgId header before handling MCP request."""
622+ token = None
623+
624+ if scope ['type' ] == 'http' :
625+ # Extract X-Scope-OrgId from headers
626+ scope_org_id = extract_header_from_scope (scope , "x-scope-orgid" )
627+ if scope_org_id :
628+ token = _current_scope_org_id .set (scope_org_id )
629+
630+ try :
631+ # Pass to the actual transport handler
632+ await transport .handle_request (scope , receive , send )
633+ finally :
634+ # Reset ContextVar to restore previous value
635+ if token is not None :
636+ _current_scope_org_id .reset (token )
489637
490638 routes = [
491- Mount ("/mcp" , app = transport . handle_request ),
639+ Mount ("/mcp" , app = handle_mcp_request ),
492640 ]
493641
494642 app = Starlette (debug = debug , routes = routes )
0 commit comments