Skip to content

Commit 928baff

Browse files
authored
Merge pull request #7 from gespi1/master
Add multi-tenant support via X-Scope-OrgId header
2 parents ad336b6 + 7dec17c commit 928baff

File tree

3 files changed

+188
-28
lines changed

3 files changed

+188
-28
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build/
55
dist/
66
wheels/
77
*.egg-info
8-
8+
k8s/**
99
# Virtual environments
1010
.venv
1111

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Prometheus Alertmanager MCP is a [Model Context Protocol](https://modelcontextpr
3333
- [x] Create, update, and delete silences
3434
- [x] Create new alerts
3535
- [x] Authentication support (Basic auth via environment variables)
36+
- [x] Multi-tenant support (via `X-Scope-OrgId` header for Mimir/Cortex)
3637
- [x] Docker containerization support
3738

3839
## 3. Quickstart
@@ -68,8 +69,18 @@ $ git clone https://github.com/ntk148v/alertmanager-mcp-server.git
6869
ALERTMANAGER_URL=http://your-alertmanager:9093
6970
ALERTMANAGER_USERNAME=your_username # optional
7071
ALERTMANAGER_PASSWORD=your_password # optional
72+
ALERTMANAGER_TENANT=your_tenant_id # optional, for multi-tenant setups
7173
```
7274

75+
#### Multi-tenant Support
76+
77+
For multi-tenant Alertmanager deployments (e.g., Grafana Mimir, Cortex), you can specify the tenant ID in two ways:
78+
79+
1. **Static configuration**: Set `ALERTMANAGER_TENANT` environment variable
80+
2. **Per-request**: Include `X-Scope-OrgId` header in requests to the MCP server
81+
82+
The `X-Scope-OrgId` header takes precedence over the static configuration, allowing dynamic tenant switching per request.
83+
7384
#### Transport configuration
7485

7586
You can control how the MCP server communicates with clients using the transport options and host/port settings. These can be set either with command-line flags (which take precedence) or with environment variables.
@@ -144,6 +155,7 @@ $ make install
144155
$ docker run -e ALERTMANAGER_URL=http://your-alertmanager:9093 \
145156
-e ALERTMANAGER_USERNAME=your_username \
146157
-e ALERTMANAGER_PASSWORD=your_password \
158+
-e ALERTMANAGER_TENANT=your_tenant_id \
147159
-p 8000:8000 ghcr.io/ntk148v/alertmanager-mcp-server
148160
```
149161

src/alertmanager_mcp_server/server.py

Lines changed: 175 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
#!/usr/bin/env python
22
import os
3+
from contextvars import ContextVar
34
from dataclasses import dataclass
45
from typing import Any, Dict, Optional, List
56

67
from mcp.server import Server
78
from mcp.server.fastmcp import FastMCP
89
from mcp.server.sse import SseServerTransport
910
from mcp.server.streamable_http import StreamableHTTPServerTransport
10-
from requests.compat import urljoin
1111
from starlette.applications import Starlette
1212
from starlette.requests import Request
1313
from starlette.routing import Mount, Route
@@ -18,19 +18,71 @@
1818
dotenv.load_dotenv()
1919
mcp = 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
2372
class 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

3081
config = 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:
4294
MAX_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+
45137
def 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

78190
def 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("\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")
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

Comments
 (0)