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>
275 lines
9.4 KiB
Python
275 lines
9.4 KiB
Python
"""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())
|