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

@@ -52,6 +52,39 @@ PROXMOX_SSH_PASSWORD=your-proxmox-password
# Generate key: ssh-keygen -t rsa -b 4096 # Generate key: ssh-keygen -t rsa -b 4096
# Copy to Proxmox: ssh-copy-id root@192.168.2.100 # 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) # Obsidian MCP Integration (Optional)
# ======================================== # ========================================

View 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

View 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",
]

View 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")

View File

@@ -1 +1,7 @@
# Loki MCP Server - Query homelab logs via Loki's HTTP API # 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)

View File

@@ -1,14 +1,14 @@
""" """
Loki MCP Server - Configuration Loki MCP Server - Configuration
This is where we store settings for connecting to your Loki instance. Settings for connecting to your Loki instance via its HTTP API.
We use environment variables with sensible defaults so you can override Uses environment variables with sensible defaults.
them without editing code.
Environment variables: Environment variables:
LOKI_URL - Base URL for your Loki instance LOKI_URL - Base URL for your Loki instance
LOKI_TIMEOUT - Request timeout in seconds (default: 30) LOKI_TIMEOUT - Request timeout in seconds (default: 30)
LOKI_DEFAULT_LIMIT - Default number of log lines to return (default: 100) LOKI_DEFAULT_LIMIT - Default number of log lines to return (default: 100)
LOKI_MCP_ENABLED - Enable/disable integration (default: true)
""" """
import os import os
@@ -18,7 +18,7 @@ import os
# Connection settings # 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") LOKI_URL = os.getenv("LOKI_URL", "https://loki.apophisnetworking.net")
# How long (seconds) to wait for Loki to respond before giving up. # 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. # 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_LIMIT = int(os.getenv("LOKI_DEFAULT_LIMIT", "100"))
# Default time range for queries if none specified (in hours). # Default time range for queries if none specified (in hours).
# "1" means "show me the last hour of logs."
DEFAULT_RANGE_HOURS = 1 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")

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 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: class LokiClient:
"""Talks to Loki's HTTP API to fetch logs.""" """Async HTTP client for Loki's query API."""
def __init__(self, config: LokiConfig): def __init__(
# Store the config so we can use it later self,
self.config = config 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 async def _get_client(self) -> httpx.AsyncClient:
# already knows Loki address and wait time """Lazy-init the async HTTP client."""
self.client = httpx.AsyncClient( if self._client is None or self._client.is_closed:
base_url=config.url, self._client = httpx.AsyncClient(
timeout=config.timeout 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 # Core queries
response = await self.client.get( # ------------------------------------------------------------------
"/loki/api/v1/query_range",
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 = { params = {
"query": query, "query": query,
"start": start, "start": start,
"end": end, "end": end,
"limit": limit "limit": limit or DEFAULT_LIMIT,
} }
)
# Returns response into Python Dict client = await self._get_client()
return response.json() 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

View 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",
]

View 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())

View 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)
# ============================================================