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:
33
.env.example
33
.env.example
@@ -52,6 +52,39 @@ PROXMOX_SSH_PASSWORD=your-proxmox-password
|
||||
# Generate key: ssh-keygen -t rsa -b 4096
|
||||
# Copy to Proxmox: ssh-copy-id root@192.168.2.100
|
||||
|
||||
# ========================================
|
||||
# Cloudflare MCP Integration (Optional)
|
||||
# ========================================
|
||||
# Cloudflare Code Mode MCP server exposes the entire Cloudflare API
|
||||
# See: mcp_servers/cloudflare/config.py for details
|
||||
|
||||
# Enable/disable Cloudflare MCP integration
|
||||
CLOUDFLARE_MCP_ENABLED=false
|
||||
|
||||
# Cloudflare API Token (create at https://dash.cloudflare.com/profile/api-tokens)
|
||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token-here
|
||||
|
||||
# Cloudflare MCP remote server URL (default: https://mcp.cloudflare.com/mcp)
|
||||
# CLOUDFLARE_MCP_URL=https://mcp.cloudflare.com/mcp
|
||||
|
||||
# ========================================
|
||||
# Loki MCP Integration (Optional)
|
||||
# ========================================
|
||||
# Loki MCP server provides log querying and analysis via Loki HTTP API
|
||||
# See: mcp_servers/loki/config.py for details
|
||||
|
||||
# Enable/disable Loki MCP integration
|
||||
LOKI_MCP_ENABLED=false
|
||||
|
||||
# Loki instance URL (via reverse proxy)
|
||||
LOKI_URL=https://loki.apophisnetworking.net
|
||||
|
||||
# Request timeout in seconds (default: 30)
|
||||
# LOKI_TIMEOUT=30
|
||||
|
||||
# Default number of log lines to return (default: 100)
|
||||
# LOKI_DEFAULT_LIMIT=100
|
||||
|
||||
# ========================================
|
||||
# Obsidian MCP Integration (Optional)
|
||||
# ========================================
|
||||
|
||||
9
mcp_servers/cloudflare/__init__.py
Normal file
9
mcp_servers/cloudflare/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Cloudflare Code Mode MCP Server
|
||||
#
|
||||
# Remote MCP server at https://mcp.cloudflare.com/mcp
|
||||
# Uses "Code Mode" — 2 tools (search + execute) covering the entire
|
||||
# Cloudflare API (2,500+ endpoints) in ~1,000 tokens.
|
||||
#
|
||||
# Auth: Cloudflare API Token (Bearer header via mcp-remote bridge)
|
||||
# Docs: https://blog.cloudflare.com/code-mode-mcp/
|
||||
# Repo: https://github.com/cloudflare/mcp
|
||||
81
mcp_servers/cloudflare/cloudflare_mcp.py
Normal file
81
mcp_servers/cloudflare/cloudflare_mcp.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Cloudflare Code Mode MCP Server Integration.
|
||||
|
||||
Manages the remote Cloudflare MCP server connection via mcp-remote bridge.
|
||||
The server exposes the entire Cloudflare API (2,500+ endpoints) through
|
||||
just two tools: search() and execute(), using ~1,000 tokens total.
|
||||
|
||||
Architecture:
|
||||
Your bot → npx mcp-remote → https://mcp.cloudflare.com/mcp
|
||||
Auth is via Cloudflare API Token passed as Bearer header.
|
||||
|
||||
Pattern mirrors obsidian_mcp.py for consistency.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
"""Load Cloudflare MCP configuration from environment."""
|
||||
from mcp_servers.cloudflare.config import (
|
||||
CLOUDFLARE_API_TOKEN,
|
||||
CLOUDFLARE_MCP_URL,
|
||||
CLOUDFLARE_MCP_ENABLED,
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": CLOUDFLARE_MCP_ENABLED,
|
||||
"api_token": CLOUDFLARE_API_TOKEN,
|
||||
"mcp_url": CLOUDFLARE_MCP_URL,
|
||||
}
|
||||
|
||||
|
||||
def is_cloudflare_enabled() -> bool:
|
||||
"""Check if the Cloudflare MCP integration is enabled and has a token."""
|
||||
config = _load_config()
|
||||
if not config["enabled"]:
|
||||
return False
|
||||
if not config["api_token"]:
|
||||
logger.warning(
|
||||
"[Cloudflare MCP] Enabled but CLOUDFLARE_API_TOKEN is not set"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_cloudflare_server_config() -> Dict[str, Any]:
|
||||
"""Build the MCP server configuration for Agent SDK registration.
|
||||
|
||||
Returns the config dict suitable for ClaudeAgentOptions.mcp_servers.
|
||||
Uses npx mcp-remote as a stdio bridge to the remote Cloudflare server.
|
||||
|
||||
The API token is passed via the --header flag as a Bearer token.
|
||||
"""
|
||||
config = _load_config()
|
||||
|
||||
return {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
config["mcp_url"],
|
||||
"--header",
|
||||
f"Authorization: Bearer {config['api_token']}",
|
||||
],
|
||||
"env": {
|
||||
# Pass through any needed env vars for npx/node resolution
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
"HOME": os.environ.get("HOME", os.environ.get("USERPROFILE", "")),
|
||||
"APPDATA": os.environ.get("APPDATA", ""),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Tools exposed by the Cloudflare Code Mode MCP server.
|
||||
# These are the only two tools — that's the whole point of Code Mode.
|
||||
CLOUDFLARE_TOOLS: List[str] = [
|
||||
"search",
|
||||
"execute",
|
||||
]
|
||||
37
mcp_servers/cloudflare/config.py
Normal file
37
mcp_servers/cloudflare/config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Cloudflare Code Mode MCP Server - Configuration
|
||||
|
||||
Remote MCP server that exposes the entire Cloudflare API through just two tools:
|
||||
- search(): Query the OpenAPI spec to find endpoints
|
||||
- execute(): Run JavaScript against the Cloudflare API
|
||||
|
||||
Environment variables:
|
||||
CLOUDFLARE_API_TOKEN - Your Cloudflare API token (required)
|
||||
CLOUDFLARE_MCP_URL - Remote MCP server URL (default: https://mcp.cloudflare.com/mcp)
|
||||
CLOUDFLARE_MCP_ENABLED - Enable/disable integration (default: true)
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cloudflare API token — create one at https://dash.cloudflare.com/profile/api-tokens
|
||||
# Recommended permissions: Account Resources (Read) + whatever you need
|
||||
CLOUDFLARE_API_TOKEN = os.getenv("CLOUDFLARE_API_TOKEN", "")
|
||||
|
||||
# The remote MCP server URL (Cloudflare runs this as a Worker)
|
||||
CLOUDFLARE_MCP_URL = os.getenv(
|
||||
"CLOUDFLARE_MCP_URL", "https://mcp.cloudflare.com/mcp"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Set to "false" to disable the integration without removing config
|
||||
CLOUDFLARE_MCP_ENABLED = os.getenv(
|
||||
"CLOUDFLARE_MCP_ENABLED", "true"
|
||||
).lower() in ("true", "1", "yes")
|
||||
@@ -1 +1,7 @@
|
||||
# Loki MCP Server - Query homelab logs via Loki's HTTP API
|
||||
#
|
||||
# Modules:
|
||||
# config.py - Environment-based configuration
|
||||
# loki_client.py - Async HTTP client for Loki API
|
||||
# loki_mcp.py - Integration module for Agent SDK registration
|
||||
# loki_server.py - MCP server with tool definitions (runs as subprocess)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""
|
||||
Loki MCP Server - Configuration
|
||||
|
||||
This is where we store settings for connecting to your Loki instance.
|
||||
We use environment variables with sensible defaults so you can override
|
||||
them without editing code.
|
||||
Settings for connecting to your Loki instance via its HTTP API.
|
||||
Uses environment variables with sensible defaults.
|
||||
|
||||
Environment variables:
|
||||
LOKI_URL - Base URL for your Loki instance
|
||||
LOKI_TIMEOUT - Request timeout in seconds (default: 30)
|
||||
LOKI_DEFAULT_LIMIT - Default number of log lines to return (default: 100)
|
||||
LOKI_MCP_ENABLED - Enable/disable integration (default: true)
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -18,7 +18,7 @@ import os
|
||||
# Connection settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The URL where Loki is reachable. This goes through your Caddy reverse proxy.
|
||||
# The URL where Loki is reachable (through Caddy reverse proxy).
|
||||
LOKI_URL = os.getenv("LOKI_URL", "https://loki.apophisnetworking.net")
|
||||
|
||||
# How long (seconds) to wait for Loki to respond before giving up.
|
||||
@@ -29,10 +29,16 @@ LOKI_TIMEOUT = int(os.getenv("LOKI_TIMEOUT", "30"))
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# How many log lines to return if the caller doesn't specify.
|
||||
# 100 is a good balance — enough to see what's happening, not so many
|
||||
# that it floods the response.
|
||||
DEFAULT_LIMIT = int(os.getenv("LOKI_DEFAULT_LIMIT", "100"))
|
||||
|
||||
# Default time range for queries if none specified (in hours).
|
||||
# "1" means "show me the last hour of logs."
|
||||
DEFAULT_RANGE_HOURS = 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Set to "false" to disable the integration without removing config
|
||||
LOKI_MCP_ENABLED = os.getenv(
|
||||
"LOKI_MCP_ENABLED", "true"
|
||||
).lower() in ("true", "1", "yes")
|
||||
|
||||
@@ -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={
|
||||
# ------------------------------------------------------------------
|
||||
# Core queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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
|
||||
"limit": limit or DEFAULT_LIMIT,
|
||||
}
|
||||
)
|
||||
|
||||
# Returns response into Python Dict
|
||||
return response.json()
|
||||
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
|
||||
|
||||
80
mcp_servers/loki/loki_mcp.py
Normal file
80
mcp_servers/loki/loki_mcp.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Loki MCP Server Integration.
|
||||
|
||||
Manages the local Loki MCP server that exposes homelab log querying
|
||||
through MCP tools. Unlike Cloudflare (remote via mcp-remote), this runs
|
||||
a local Python MCP server that talks to Loki's HTTP API directly.
|
||||
|
||||
Architecture:
|
||||
Garvis → (stdio) → loki_server.py → HTTP → Loki (loki.apophisnetworking.net)
|
||||
|
||||
Pattern mirrors cloudflare_mcp.py for consistency.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_config() -> Dict[str, Any]:
|
||||
"""Load Loki MCP configuration from environment."""
|
||||
from mcp_servers.loki.config import (
|
||||
LOKI_URL,
|
||||
LOKI_TIMEOUT,
|
||||
LOKI_MCP_ENABLED,
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": LOKI_MCP_ENABLED,
|
||||
"url": LOKI_URL,
|
||||
"timeout": LOKI_TIMEOUT,
|
||||
}
|
||||
|
||||
|
||||
def is_loki_enabled() -> bool:
|
||||
"""Check if the Loki MCP integration is enabled."""
|
||||
config = _load_config()
|
||||
if not config["enabled"]:
|
||||
return False
|
||||
if not config["url"]:
|
||||
logger.warning("[Loki MCP] Enabled but LOKI_URL is not set")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_loki_server_config() -> Dict[str, Any]:
|
||||
"""Build the MCP server configuration for Agent SDK registration.
|
||||
|
||||
Returns the config dict suitable for ClaudeAgentOptions.mcp_servers.
|
||||
This runs a local Python MCP server via stdio (not mcp-remote).
|
||||
"""
|
||||
# Path to the MCP server script
|
||||
server_script = os.path.join(
|
||||
os.path.dirname(__file__), "loki_server.py"
|
||||
)
|
||||
|
||||
return {
|
||||
"command": sys.executable, # Use the same Python interpreter
|
||||
"args": [server_script],
|
||||
"env": {
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
"HOME": os.environ.get("HOME", os.environ.get("USERPROFILE", "")),
|
||||
"APPDATA": os.environ.get("APPDATA", ""),
|
||||
# Pass Loki config through to the subprocess
|
||||
"LOKI_URL": os.environ.get("LOKI_URL", "https://loki.apophisnetworking.net"),
|
||||
"LOKI_TIMEOUT": os.environ.get("LOKI_TIMEOUT", "30"),
|
||||
"LOKI_DEFAULT_LIMIT": os.environ.get("LOKI_DEFAULT_LIMIT", "100"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Tools exposed by the Loki MCP server.
|
||||
LOKI_TOOLS: List[str] = [
|
||||
"loki_query",
|
||||
"loki_labels",
|
||||
"loki_label_values",
|
||||
"loki_series",
|
||||
"loki_health",
|
||||
]
|
||||
274
mcp_servers/loki/loki_server.py
Normal file
274
mcp_servers/loki/loki_server.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Loki MCP Server — Exposes homelab log querying via MCP tools.
|
||||
|
||||
Runs as a stdio-based MCP server. The Agent SDK spawns this as a
|
||||
subprocess and communicates via JSON-RPC over stdin/stdout.
|
||||
|
||||
Tools:
|
||||
loki_query - Run a LogQL query and get log lines
|
||||
loki_labels - List all known label names
|
||||
loki_label_values - Get values for a specific label
|
||||
loki_series - List active log streams
|
||||
loki_health - Check if Loki is reachable
|
||||
|
||||
Usage (standalone test):
|
||||
python loki_server.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent paths so imports work when run as subprocess
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from mcp_servers.loki.loki_client import LokiClient
|
||||
|
||||
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create the MCP server
|
||||
server = Server("loki")
|
||||
|
||||
# Create the Loki client (uses env vars from config)
|
||||
client = LokiClient()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool definitions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""Return the list of tools this server exposes."""
|
||||
return [
|
||||
Tool(
|
||||
name="loki_query",
|
||||
description=(
|
||||
"Query logs from Loki using LogQL. "
|
||||
"Examples: '{job=\"varlogs\"} |= \"error\"', "
|
||||
"'{container=\"caddy\"} | json | status >= 500'. "
|
||||
"Returns log lines with timestamps and labels."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"LogQL query expression. Must include a stream selector "
|
||||
"in curly braces. Examples:\n"
|
||||
" {job=\"varlogs\"}\n"
|
||||
" {container=\"caddy\"} |= \"error\"\n"
|
||||
" {host=\"proxmox\"} | json | level=\"error\""
|
||||
),
|
||||
},
|
||||
"hours": {
|
||||
"type": "integer",
|
||||
"description": "How many hours back to search (default: 1)",
|
||||
"default": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max number of log lines to return (default: 100)",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="loki_labels",
|
||||
description=(
|
||||
"List all known label names in Loki. Use this to discover "
|
||||
"what labels are available for querying (e.g. job, host, "
|
||||
"container, filename)."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="loki_label_values",
|
||||
description=(
|
||||
"Get all values for a specific label. Useful for discovering "
|
||||
"what jobs, hosts, or containers are sending logs. "
|
||||
"Example: label='job' might return ['varlogs', 'caddy', 'grafana']."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "The label name to get values for (e.g. 'job', 'host', 'container')",
|
||||
},
|
||||
},
|
||||
"required": ["label"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="loki_series",
|
||||
description=(
|
||||
"List active log streams/series. Shows what label combinations "
|
||||
"are actively producing logs. Optionally filter with a stream selector."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"match": {
|
||||
"type": "string",
|
||||
"description": "Optional LogQL stream selector to filter, e.g. '{job=\"varlogs\"}'",
|
||||
},
|
||||
"hours": {
|
||||
"type": "integer",
|
||||
"description": "How many hours back to search (default: 1)",
|
||||
"default": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="loki_health",
|
||||
description="Check if Loki is reachable and responding.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
"""Handle tool calls."""
|
||||
try:
|
||||
if name == "loki_query":
|
||||
return await _handle_query(arguments)
|
||||
elif name == "loki_labels":
|
||||
return await _handle_labels()
|
||||
elif name == "loki_label_values":
|
||||
return await _handle_label_values(arguments)
|
||||
elif name == "loki_series":
|
||||
return await _handle_series(arguments)
|
||||
elif name == "loki_health":
|
||||
return await _handle_health()
|
||||
else:
|
||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
except Exception as e:
|
||||
logger.error("[Loki MCP] Error in %s: %s", name, e, exc_info=True)
|
||||
return [TextContent(type="text", text=f"Error: {str(e)}")]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_query(args: dict) -> list[TextContent]:
|
||||
"""Run a LogQL query and return formatted log lines."""
|
||||
query = args["query"]
|
||||
hours = args.get("hours", 1)
|
||||
limit = args.get("limit", 100)
|
||||
|
||||
result = await client.query_range(query=query, hours=hours, limit=limit)
|
||||
|
||||
# Check for errors in the response
|
||||
if result.get("status") != "success":
|
||||
error_msg = result.get("message", "Unknown error")
|
||||
return [TextContent(type="text", text=f"Loki query error: {error_msg}")]
|
||||
|
||||
# Extract readable log lines
|
||||
lines = client.extract_lines(result)
|
||||
|
||||
if not lines:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=f"No logs found for query: {query} (last {hours}h)",
|
||||
)]
|
||||
|
||||
# Format output
|
||||
output_parts = [f"Found {len(lines)} log lines for: {query} (last {hours}h)\n"]
|
||||
for entry in lines:
|
||||
output_parts.append(
|
||||
f"[{entry['timestamp']}] ({entry['labels']}) {entry['line']}"
|
||||
)
|
||||
|
||||
# Truncate if too long (prevent token explosion)
|
||||
output = "\n".join(output_parts)
|
||||
if len(output) > 15000:
|
||||
output = output[:15000] + f"\n\n... truncated ({len(lines)} total lines)"
|
||||
|
||||
return [TextContent(type="text", text=output)]
|
||||
|
||||
|
||||
async def _handle_labels() -> list[TextContent]:
|
||||
"""List all label names."""
|
||||
labels = await client.labels()
|
||||
if not labels:
|
||||
return [TextContent(type="text", text="No labels found in Loki.")]
|
||||
|
||||
output = f"Available labels ({len(labels)}):\n" + "\n".join(f" - {l}" for l in sorted(labels))
|
||||
return [TextContent(type="text", text=output)]
|
||||
|
||||
|
||||
async def _handle_label_values(args: dict) -> list[TextContent]:
|
||||
"""Get values for a specific label."""
|
||||
label = args["label"]
|
||||
values = await client.label_values(label)
|
||||
|
||||
if not values:
|
||||
return [TextContent(type="text", text=f"No values found for label '{label}'.")]
|
||||
|
||||
output = f"Values for '{label}' ({len(values)}):\n" + "\n".join(f" - {v}" for v in sorted(values))
|
||||
return [TextContent(type="text", text=output)]
|
||||
|
||||
|
||||
async def _handle_series(args: dict) -> list[TextContent]:
|
||||
"""List active streams/series."""
|
||||
match_str = args.get("match")
|
||||
hours = args.get("hours", 1)
|
||||
|
||||
match_list = [match_str] if match_str else None
|
||||
series = await client.series(match=match_list, hours=hours)
|
||||
|
||||
if not series:
|
||||
return [TextContent(type="text", text="No active series found.")]
|
||||
|
||||
output_parts = [f"Active streams ({len(series)}):"]
|
||||
for s in series[:50]: # Cap at 50 to avoid huge output
|
||||
labels_str = ", ".join(f'{k}="{v}"' for k, v in s.items())
|
||||
output_parts.append(f" {{{labels_str}}}")
|
||||
|
||||
if len(series) > 50:
|
||||
output_parts.append(f"\n ... and {len(series) - 50} more")
|
||||
|
||||
return [TextContent(type="text", text="\n".join(output_parts))]
|
||||
|
||||
|
||||
async def _handle_health() -> list[TextContent]:
|
||||
"""Check Loki health."""
|
||||
healthy = await client.health()
|
||||
if healthy:
|
||||
return [TextContent(type="text", text=f"✅ Loki is healthy and reachable at {client.url}")]
|
||||
else:
|
||||
return [TextContent(type="text", text=f"❌ Loki is NOT reachable at {client.url}")]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def main():
|
||||
"""Run the MCP server over stdio."""
|
||||
logger.info("[Loki MCP] Starting server (Loki URL: %s)", client.url)
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
85
promtail-config-optimized.yaml
Normal file
85
promtail-config-optimized.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
grpc_listen_port: 0
|
||||
|
||||
positions:
|
||||
filename: /tmp/positions.yaml
|
||||
|
||||
clients:
|
||||
- url: http://loki:3100/loki/api/v1/push
|
||||
|
||||
scrape_configs:
|
||||
- job_name: syslog_ingest
|
||||
syslog:
|
||||
listen_address: 0.0.0.0:1514
|
||||
listen_protocol: tcp
|
||||
idle_timeout: 60s
|
||||
label_structured_data: yes
|
||||
labels:
|
||||
job: "syslog_combined"
|
||||
relabel_configs:
|
||||
- source_labels: ['__syslog_message_hostname']
|
||||
target_label: 'host'
|
||||
|
||||
# ============================================================
|
||||
# SYSLOG NOISE FILTERS
|
||||
# Estimated ~80-85% volume reduction from Dream Router
|
||||
# Applied: 2026-02-23
|
||||
# ============================================================
|
||||
pipeline_stages:
|
||||
# --- HIGH VOLUME DROPS (~60-70% of all logs) ---
|
||||
|
||||
# mDNS multicast (IPv4) - Apple/Chromecast/IoT discovery
|
||||
# Fires across EVERY VLAN (br0, br2, br5, br10, br11, br12)
|
||||
- drop:
|
||||
expression: 'DST=224\.0\.0\.251'
|
||||
drop_counter_reason: "mdns_ipv4_multicast"
|
||||
|
||||
# mDNS multicast (IPv6)
|
||||
- drop:
|
||||
expression: 'DST=ff02::fb'
|
||||
drop_counter_reason: "mdns_ipv6_multicast"
|
||||
|
||||
# mDNS port catch-all (anything remaining on port 5353)
|
||||
- drop:
|
||||
expression: 'DPT=5353'
|
||||
drop_counter_reason: "mdns_port_5353"
|
||||
|
||||
# --- MEDIUM VOLUME DROPS (~15-20%) ---
|
||||
|
||||
# mca-ctrl / stahtd daemon noise - fires every 2-3 seconds
|
||||
- drop:
|
||||
expression: 'no input for event'
|
||||
drop_counter_reason: "mca_ctrl_stahtd_noise"
|
||||
|
||||
# --- LOW VOLUME DROPS (~3-5%) ---
|
||||
|
||||
# UniFi device discovery broadcasts
|
||||
- drop:
|
||||
expression: 'DPT=10001'
|
||||
drop_counter_reason: "unifi_discovery"
|
||||
|
||||
# hostapd WiFi AP check systemd spam (~every 30s)
|
||||
- drop:
|
||||
expression: 'hostapd-global-check'
|
||||
drop_counter_reason: "hostapd_check_spam"
|
||||
|
||||
# Duplicate DNAT entries for port forwards (keeps the WAN_IN Allow line)
|
||||
- drop:
|
||||
expression: 'PortForward.*DNAT'
|
||||
drop_counter_reason: "duplicate_dnat"
|
||||
|
||||
# Internal ICMP gateway pings - devices checking if gateway alive
|
||||
- drop:
|
||||
expression: 'PROTO=ICMP.*DST=192\.168\.'
|
||||
drop_counter_reason: "internal_icmp_pings"
|
||||
|
||||
# ============================================================
|
||||
# WHAT WE KEEP:
|
||||
# - [WAN_LOCAL]Block → real attack attempts (security value)
|
||||
# - [WAN_IN]Allow → legit inbound traffic log
|
||||
# - Daemon errors/warnings
|
||||
# - DHCP/DNS logs
|
||||
# - mcad interval changes (rare, informational)
|
||||
# - Everything from serviceslab (Proxmox host)
|
||||
# ============================================================
|
||||
Reference in New Issue
Block a user