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:
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())
|
||||
Reference in New Issue
Block a user