diff --git a/.env.example b/.env.example index 5758925..5a7f2fa 100644 --- a/.env.example +++ b/.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) # ======================================== diff --git a/mcp_servers/cloudflare/__init__.py b/mcp_servers/cloudflare/__init__.py new file mode 100644 index 0000000..a79896c --- /dev/null +++ b/mcp_servers/cloudflare/__init__.py @@ -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 diff --git a/mcp_servers/cloudflare/cloudflare_mcp.py b/mcp_servers/cloudflare/cloudflare_mcp.py new file mode 100644 index 0000000..446d48e --- /dev/null +++ b/mcp_servers/cloudflare/cloudflare_mcp.py @@ -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", +] diff --git a/mcp_servers/cloudflare/config.py b/mcp_servers/cloudflare/config.py new file mode 100644 index 0000000..38b8611 --- /dev/null +++ b/mcp_servers/cloudflare/config.py @@ -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") diff --git a/mcp_servers/loki/__init__.py b/mcp_servers/loki/__init__.py index d3f292b..c165ddf 100644 --- a/mcp_servers/loki/__init__.py +++ b/mcp_servers/loki/__init__.py @@ -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) diff --git a/mcp_servers/loki/config.py b/mcp_servers/loki/config.py index 2c76230..deda28b 100644 --- a/mcp_servers/loki/config.py +++ b/mcp_servers/loki/config.py @@ -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_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") diff --git a/mcp_servers/loki/loki_client.py b/mcp_servers/loki/loki_client.py index 0aa619b..faa3d30 100644 --- a/mcp_servers/loki/loki_client.py +++ b/mcp_servers/loki/loki_client.py @@ -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-). + 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 diff --git a/mcp_servers/loki/loki_mcp.py b/mcp_servers/loki/loki_mcp.py new file mode 100644 index 0000000..e6db200 --- /dev/null +++ b/mcp_servers/loki/loki_mcp.py @@ -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", +] diff --git a/mcp_servers/loki/loki_server.py b/mcp_servers/loki/loki_server.py new file mode 100644 index 0000000..ae40718 --- /dev/null +++ b/mcp_servers/loki/loki_server.py @@ -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()) diff --git a/promtail-config-optimized.yaml b/promtail-config-optimized.yaml new file mode 100644 index 0000000..ced3df9 --- /dev/null +++ b/promtail-config-optimized.yaml @@ -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) + # ============================================================