Add Cloudflare and Loki MCP server integrations

Features:
- Cloudflare Code Mode MCP: Exposes entire Cloudflare API (2,500+ endpoints)
  via remote MCP server at https://mcp.cloudflare.com/mcp
  * Two tools: search() to query OpenAPI spec, execute() to run JS code
  * Uses npx mcp-remote as stdio bridge
  * Auth via CLOUDFLARE_API_TOKEN as Bearer header

- Loki MCP Server: Log querying and analysis via Loki HTTP API
  * Query logs with LogQL syntax
  * Real-time log streaming support
  * Label introspection and metrics queries
  * Configurable via LOKI_URL environment variable

Technical changes:
- Created mcp_servers/cloudflare/ with config and connection logic
- Created mcp_servers/loki/ with HTTP client and MCP tool wrappers
- Added promtail-config-optimized.yaml for syslog ingestion config
- Updated .env.example with Cloudflare and Loki configuration templates

Both integrations:
- Use environment variables for configuration (no hardcoded credentials)
- Include feature flags (CLOUDFLARE_MCP_ENABLED, LOKI_MCP_ENABLED)
- Follow existing MCP server patterns for consistency

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 12:35:04 -07:00
parent 58de3e55dc
commit bb86a9eef5
10 changed files with 801 additions and 33 deletions

View File

@@ -1,32 +1,189 @@
"""Loki HTTP API Client.
Handles all communication with Loki's query endpoints:
- /loki/api/v1/query_range (log queries over a time window)
- /loki/api/v1/labels (list all label names)
- /loki/api/v1/label/{name}/values (values for a specific label)
- /loki/api/v1/series (active label sets / streams)
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
import httpx
from config import LokiConfig
from mcp_servers.loki.config import (
LOKI_URL,
LOKI_TIMEOUT,
DEFAULT_LIMIT,
DEFAULT_RANGE_HOURS,
)
logger = logging.getLogger(__name__)
class LokiClient:
"""Talks to Loki's HTTP API to fetch logs."""
"""Async HTTP client for Loki's query API."""
def __init__(self, config: LokiConfig):
# Store the config so we can use it later
self.config = config
def __init__(
self,
url: Optional[str] = None,
timeout: Optional[int] = None,
):
self.url = url or LOKI_URL
self.timeout = timeout or LOKI_TIMEOUT
self._client: Optional[httpx.AsyncClient] = None
# Create an HTTP client
# already knows Loki address and wait time
self.client = httpx.AsyncClient(
base_url=config.url,
timeout=config.timeout
async def _get_client(self) -> httpx.AsyncClient:
"""Lazy-init the async HTTP client."""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
base_url=self.url,
timeout=self.timeout,
)
return self._client
async def close(self):
"""Close the HTTP client."""
if self._client and not self._client.is_closed:
await self._client.aclose()
# ------------------------------------------------------------------
# Time helpers
# ------------------------------------------------------------------
@staticmethod
def _to_nano_ts(dt: datetime) -> str:
"""Convert a datetime to Loki's nanosecond-epoch string."""
return str(int(dt.timestamp() * 1_000_000_000))
@staticmethod
def _default_range(hours: Optional[int] = None):
"""Return (start, end) as nano-epoch strings for the last N hours."""
now = datetime.now(timezone.utc)
hrs = hours or DEFAULT_RANGE_HOURS
start = now - timedelta(hours=hrs)
return (
LokiClient._to_nano_ts(start),
LokiClient._to_nano_ts(now),
)
async def query_range(self, query: str, start: str, end: str, limit: int = 100):
# Makes GET request to Loki's query endpoint with search parameters
response = await self.client.get(
"/loki/api/v1/query_range",
params={
"query": query,
"start": start,
"end": end,
"limit": limit
}
)
# ------------------------------------------------------------------
# Core queries
# ------------------------------------------------------------------
# Returns response into Python Dict
return response.json()
async def query_range(
self,
query: str,
start: Optional[str] = None,
end: Optional[str] = None,
limit: Optional[int] = None,
hours: Optional[int] = None,
) -> Dict[str, Any]:
"""Run a LogQL query over a time range.
Args:
query: LogQL expression, e.g. '{job="varlogs"} |= "error"'
start: Nano-epoch start (optional — defaults to now-<hours>).
end: Nano-epoch end (optional — defaults to now).
limit: Max log lines to return.
hours: Shorthand for "last N hours" (ignored if start/end given).
Returns:
Raw JSON response from Loki (dict).
"""
if not start or not end:
start, end = self._default_range(hours)
params = {
"query": query,
"start": start,
"end": end,
"limit": limit or DEFAULT_LIMIT,
}
client = await self._get_client()
logger.debug("[Loki] query_range: %s", params)
resp = await client.get("/loki/api/v1/query_range", params=params)
resp.raise_for_status()
return resp.json()
async def labels(self) -> List[str]:
"""List all known label names."""
client = await self._get_client()
resp = await client.get("/loki/api/v1/labels")
resp.raise_for_status()
data = resp.json()
return data.get("data", [])
async def label_values(self, label: str) -> List[str]:
"""List values for a specific label (e.g. 'job', 'host')."""
client = await self._get_client()
resp = await client.get(f"/loki/api/v1/label/{label}/values")
resp.raise_for_status()
data = resp.json()
return data.get("data", [])
async def series(
self,
match: Optional[List[str]] = None,
hours: Optional[int] = None,
) -> List[Dict[str, str]]:
"""List active streams/series matching optional selectors.
Args:
match: List of LogQL stream selectors, e.g. ['{job="varlogs"}'].
hours: Time window to search (default: DEFAULT_RANGE_HOURS).
"""
start, end = self._default_range(hours)
params: Dict[str, Any] = {"start": start, "end": end}
if match:
params["match[]"] = match
client = await self._get_client()
resp = await client.get("/loki/api/v1/series", params=params)
resp.raise_for_status()
data = resp.json()
return data.get("data", [])
async def health(self) -> bool:
"""Quick health check — hits /ready endpoint."""
try:
client = await self._get_client()
resp = await client.get("/ready")
return resp.status_code == 200
except Exception as e:
logger.warning("[Loki] Health check failed: %s", e)
return False
# ------------------------------------------------------------------
# Convenience: extract just the log lines from a query_range result
# ------------------------------------------------------------------
@staticmethod
def extract_lines(result: Dict[str, Any]) -> List[Dict[str, str]]:
"""Pull log lines from a query_range response.
Returns list of {"timestamp": ..., "line": ..., "labels": ...} dicts,
sorted newest-first.
"""
lines = []
data = result.get("data", {})
for stream in data.get("result", []):
labels = stream.get("stream", {})
label_str = ", ".join(f'{k}="{v}"' for k, v in labels.items())
for ts, line in stream.get("values", []):
# Convert nano-epoch to human-readable
dt = datetime.fromtimestamp(
int(ts) / 1_000_000_000, tz=timezone.utc
)
lines.append({
"timestamp": dt.strftime("%Y-%m-%d %H:%M:%S UTC"),
"line": line,
"labels": label_str,
})
# Newest first
lines.sort(key=lambda x: x["timestamp"], reverse=True)
return lines