feat: agent platform initial commit
- FastAPI + HTMX UI: dashboard, agents CRUD, chat, audit, tools - SQLite schema: agents, conversations, messages, audit, sessions - Code-agent sync (agents/*.py -> DB on startup) - MCP client with health check - Token auth (Bearer + session) - LiteLLM integration via llm.py - Docker Compose: agent-platform + mcp-tools services - Smoke tests pass: /health 200, /api/agents 200, / 401
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server.py .
|
||||
|
||||
RUN useradd -m -u 1000 mcpuser && chown -R mcpuser:mcpuser /app
|
||||
USER mcpuser
|
||||
|
||||
EXPOSE 8501
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fastmcp>=0.4.0
|
||||
httpx>=0.27.0
|
||||
pydantic>=2.6.0
|
||||
@@ -0,0 +1,143 @@
|
||||
"""MCP-Tool-Server für die Agent Platform."""
|
||||
import os
|
||||
import ast
|
||||
import operator
|
||||
import ipaddress
|
||||
import socket
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
mcp = FastMCP("Agent Platform Tools")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def echo(text: str) -> dict:
|
||||
"""Echo-Tool zum Testen der MCP-Verbindung."""
|
||||
return {"input": text, "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_time(timezone_name: str = "UTC") -> dict:
|
||||
"""Gibt aktuelle Zeit in angegebener Zeitzone zurück."""
|
||||
tz_offsets = {"UTC": 0, "CET": 1, "CEST": 2, "EST": -5, "PST": -8, "JST": 9}
|
||||
offset = tz_offsets.get(timezone_name.upper(), 0)
|
||||
tz = timezone(timedelta(hours=offset))
|
||||
return {"timezone": timezone_name, "time": datetime.now(tz).isoformat()}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def calculate(expression: str) -> dict:
|
||||
"""Berechnet einen mathematischen Ausdruck (sicher, nur Zahlen).
|
||||
|
||||
Erlaubt: +, -, *, /, **, %.
|
||||
Beispiel: calculate("2 + 3 * 4") -> 14
|
||||
"""
|
||||
ops = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Pow: operator.pow,
|
||||
ast.Mod: operator.mod,
|
||||
}
|
||||
|
||||
try:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
except SyntaxError:
|
||||
return {"error": "Ungültiger Ausdruck"}
|
||||
|
||||
def _eval(node):
|
||||
if isinstance(node, ast.Constant):
|
||||
if not isinstance(node.value, (int, float)):
|
||||
raise ValueError("Nur Zahlen erlaubt")
|
||||
return node.value
|
||||
if isinstance(node, ast.BinOp):
|
||||
if type(node.op) not in ops:
|
||||
raise ValueError("Operation nicht erlaubt")
|
||||
return ops[type(node.op)](_eval(node.left), _eval(node.right))
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
if isinstance(node.op, ast.USub):
|
||||
return -_eval(node.operand)
|
||||
if isinstance(node.op, ast.UAdd):
|
||||
return _eval(node.operand)
|
||||
raise ValueError("Ungültiger Knoten")
|
||||
|
||||
try:
|
||||
result = _eval(tree.body)
|
||||
return {"expression": expression, "result": result}
|
||||
except Exception as e:
|
||||
return {"expression": expression, "error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def http_get(url: str, timeout: int = 10) -> dict:
|
||||
"""Holt eine URL via HTTP. SSRF-Schutz aktiv.
|
||||
|
||||
Nur http/https, keine Auth, keine privaten IPs.
|
||||
Beispiel: http_get("https://example.com")
|
||||
"""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return {"error": "Nur http/https URLs erlaubt"}
|
||||
|
||||
parsed = urlparse(url)
|
||||
try:
|
||||
ip = socket.gethostbyname(parsed.hostname)
|
||||
ip_obj = ipaddress.ip_address(ip)
|
||||
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved:
|
||||
return {"error": f"IP {ip} blockiert (privat/loopback/reserved)"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
||||
r = client.get(url)
|
||||
return {
|
||||
"status": r.status_code,
|
||||
"url": str(r.url),
|
||||
"content_type": r.headers.get("content-type", ""),
|
||||
"length": len(r.text),
|
||||
"text_preview": r.text[:2000],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_env(prefix: str = "") -> dict:
|
||||
"""Listet Umgebungsvariablen mit optionalem Prefix-Filter.
|
||||
|
||||
Sensitive Werte (KEY/SECRET/TOKEN/PASSWORD) werden maskiert.
|
||||
"""
|
||||
env = {}
|
||||
for k, v in os.environ.items():
|
||||
if prefix and not k.startswith(prefix.upper()):
|
||||
continue
|
||||
upper = k.upper()
|
||||
if any(s in upper for s in ["KEY", "SECRET", "TOKEN", "PASSWORD"]):
|
||||
env[k] = "***MASKED***"
|
||||
else:
|
||||
env[k] = v[:200]
|
||||
return {"count": len(env), "vars": env}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def json_format(data: str, indent: int = 2) -> dict:
|
||||
"""Formatiert einen JSON-String lesbar.
|
||||
|
||||
Beispiel: json_format('{"a":1}')
|
||||
"""
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
return {"formatted": json.dumps(parsed, indent=indent, ensure_ascii=False)}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = os.getenv("MCP_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("MCP_PORT", "8501"))
|
||||
mcp.run(transport="streamable-http", host=host, port=port)
|
||||
Reference in New Issue
Block a user