commit 45214ff6f3e5024738b7daa5ef92184b0681a81c Author: Thomas Adamek Date: Tue Sep 15 20:38:16 2026 +0200 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f6e7eb4 --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# Beispielkonfiguration. Alle Werte sind optional; gezeigt sind die Defaults +# bzw. typische Werte für den Betrieb im Container. + +# --- Transport --------------------------------------------------------------- +MCP_CALC_TRANSPORT=http # stdio | http +MCP_CALC_HOST=0.0.0.0 # im Container 0.0.0.0, lokal 127.0.0.1 +MCP_CALC_PORT=8080 +MCP_CALC_PATH=/mcp + +# Erlaubte Host-Header (kommagetrennt). Gesetzt = DNS-Rebinding-Schutz aktiv. +MCP_CALC_ALLOWED_HOSTS= +MCP_CALC_ALLOWED_ORIGINS= +# Proxys (z. B. OpenShift-Router), deren X-Forwarded-For ausgewertet wird (IPs/CIDRs) +MCP_CALC_TRUSTED_PROXIES= +MCP_CALC_MAX_REQUEST_BODY_BYTES=1048576 +MCP_CALC_MAX_CONNECTIONS=200 +MCP_CALC_METRICS=true # Prometheus-Metriken unter /metrics + +# --- Authentifizierung (nur HTTP, siehe docs/auth.md) ------------------------ +MCP_CALC_CLIENTS_FILE=/data/clients.json # RWX-PVC; aktiviert die Authentifizierung +MCP_CALC_CLIENTS_RELOAD_SECONDS=5 +MCP_CALC_AUDIT_FILE=/data/audit.log +# Statische Tokens name:token (einfache Alternative zur Client-Datei) +MCP_CALC_AUTH_TOKENS= +# Admin-API: startet nur, wenn Admin-Tokens gesetzt sind (Wert aus einem Secret!) +MCP_CALC_ADMIN_TOKENS= +MCP_CALC_ADMIN_HOST=127.0.0.1 +MCP_CALC_ADMIN_PORT=9090 + +# --- Ausführung -------------------------------------------------------------- +MCP_CALC_EXECUTOR=process # process | inline (nur Entwicklung) +MCP_CALC_WORKERS=2 # ~ Anzahl CPU-Kerne des Pods +MCP_CALC_MAX_QUEUE=20 # wartende Berechnungen; darüber sofort "busy" +MCP_CALC_MAX_QUEUE_WAIT_SECONDS=30 # maximale Wartezeit auf einen freien Worker +MCP_CALC_CLIENT_MAX_CONCURRENT=10 # gleichzeitige Berechnungen pro Client (0 = unbegrenzt) +MCP_CALC_TIMEOUT_SECONDS=10 +MCP_CALC_MAX_TASKS_PER_WORKER=200 +MCP_CALC_MEMORY_LIMIT_MB=512 # zusätzlicher Speicher pro Berechnung/Worker, nur Linux, 0 = aus + +# --- Eingabe-/Ausgabelimits -------------------------------------------------- +MCP_CALC_MAX_INPUT_LENGTH=5000 +MCP_CALC_MAX_OUTPUT_LENGTH=20000 +MCP_CALC_MAX_PRECISION=1000 +MCP_CALC_MAX_MATRIX_SIZE=30 +MCP_CALC_MAX_DATA_POINTS=1000000 +MCP_CALC_MAX_LIST_ITEMS=10000 + +# --- Logging ----------------------------------------------------------------- +MCP_CALC_LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR +MCP_CALC_LOG_FORMAT=json # text | json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c2a2d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.coverage +htmlcov/ +.env +.ruff_cache/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..27f0f6b --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,130 @@ +# Backlog + +Geplante Features und offene Themen für mcp-calculator. +**Priorität:** hoch / mittel / niedrig. **Aufwand:** S (≤ ½ Tag), M (1–2 Tage), L (> 2 Tage). + +--- + +## Entscheidungen + +| Thema | Entscheidung | Konsequenz | +|---|---|---| +| **Authentifizierung** | Jetzt API-Tokens, später Keycloak (OIDC/JWT). Lokal (stdio) keine Auth. | Umgesetzt in 1.2.0: Client-Datei auf RWX-PVC, CLI + Admin-API, Audit-Log, Token- und Netz-Clients. Keycloak siehe Features. HMAC-Signaturen aktuell nicht. | +| **Token-Verwaltung** | Datei auf dem PVC als einziger Speicher, Admin-API und CLI schreiben dieselbe Datei. | Keine Datenbank, funktioniert mit mehreren Replicas (RWX, Hot-Reload). | +| **Rate-Limiting** | Ziel ist **Überlastschutz**, nicht Fairness zwischen Nutzern. | Umgesetzt in 1.2.0 (sechs Schichten, pro Replica). Kein zentraler Zähler nötig. | +| **Replicas** | Aktuell eine, bei Bedarf mehrere. | Server ist zustandslos, Skalierung ohne Codeänderung möglich. Readiness und Backpressure wirken pro Pod. | + +## Offen, erledigt das Team + +| Thema | Details | +|---|---| +| Linux-Verifikation im Pod | `python scripts/measure_memory.py server`; speicherhungrige Anfrage (z. B. `evaluate` mit `factorial(10^7)`) muss mit Memory-/Timeout-Meldung enden, ohne Pod-Neustart. Siehe [docs/memory.md](docs/memory.md) | +| Clients end-to-end | opencode, Cline und eigene Tools gegen den Pod; Konfigurationsfelder der README gegen die eingesetzten Client-Versionen prüfen | +| Git-Repository | inkl. `uv.lock` und `requirements.lock` committen | + +--- + +## Features + +### Hohe Priorität + +#### Keycloak (OIDC/JWT) · Aufwand M +Zusätzlich zu API-Tokens: Keycloak-JWTs prüfen (Signatur gegen JWKS, `iss`, `aud`, Ablauf) über den +`TokenVerifier` des MCP SDK; Identität aus `azp`/Client-ID oder einem Claim, gleiche Limits und Metriken +wie heute. Umschaltbar per `MCP_CALC_AUTH_MODE=token|oidc|both`. +- **Vorher klären:** Unterstützen opencode und Cline den OAuth-Ablauf von MCP gegen Keycloak? Sonst `both` (IDE-Clients mit API-Token, Dienste mit Client-Credentials). +- **Voraussetzung:** Keycloak-Realm/Client, erreichbar aus dem Pod (JWKS). + +#### Eval-Suite für die Tool-Nutzung · Aufwand M +Realistische Aufgaben (mit bekannter Lösung) gegen das eingesetzte Modell laufen lassen, jeweils mit +und ohne MCP-Server. Gemessen werden Trefferquote, Tool-Wahl, Anzahl der Aufrufe und Fehlversuche. +- **Nutzen:** belegt den Mehrwert und zeigt, welche Tool-Beschreibungen nachgeschärft werden müssen. Basis für die Priorisierung der übrigen Features. +- **Braucht:** Modell-Zugang und idealerweise echte, anonymisierte Nutzerfragen. +- **Ansatz:** eigener MCP-Client (siehe `examples/client_example.py`) in einer LLM-Schleife, Aufgaben als JSON/YAML, Report als Tabelle. + +#### Finanzmathematik · Aufwand M +Neues Tool `finance`: Barwert/Endwert, Annuität und Tilgungsplan, NPV, IRR (interner Zinsfuß), effektiver +Jahreszins, Abschreibungen. +- **Nutzen:** häufige Business-Fragen, bei denen LLMs regelmäßig falsch runden oder Formeln verwechseln. +- **Ansatz:** exakte Arithmetik über SymPy/`Decimal`, IRR numerisch (`scipy.optimize.brentq`); Tilgungsplan als Tabelle mit Längenlimit. + +#### Datum und Zeit · Aufwand S–M +Neues Tool `datetime_calc`: Differenz zwischen Daten, Datum ± Dauer, Arbeitstage (mit Feiertagskalender +je Bundesland), Wochentag, Kalenderwoche (ISO), Zeitzonen-Umrechnung, Fristen. +- **Nutzen:** LLMs sind hier notorisch unzuverlässig (Schaltjahre, Monatslängen, Wochentage). +- **Ansatz:** Standardbibliothek `datetime`/`zoneinfo`; Feiertage über das Paket `holidays` (neue Abhängigkeit). + +### Mittlere Priorität + +#### Optimierung · Aufwand M +Neues Tool `optimize`: Minimum/Maximum einer Funktion (mit Schranken und Nebenbedingungen), lineare +Optimierung, Nullstellensuche in Intervallen. +- **Ansatz:** `scipy.optimize` (`minimize`, `linprog`, `brentq`); symbolisch über `solve` des Gradienten, wo möglich. + +#### Numerische Differentialgleichungen · Aufwand M +`solve_ode` um einen numerischen Modus erweitern: Anfangswertprobleme und Systeme ohne geschlossene Lösung, +Ergebnis als Wertetabelle an gewählten Stellen. +- **Ansatz:** `scipy.integrate.solve_ivp`; Rechte Seite über den sicheren Parser, dann `lambdify`. + +#### Batch-Aufrufe · Aufwand S +Mehrere Ausdrücke oder Aufgaben in einem Tool-Aufruf (z. B. `evaluate` mit Liste). +- **Nutzen:** weniger Roundtrips bei Tabellen und Serienrechnungen, geringere Latenz im Agenten. +- **Achtung:** Timeout und Limits pro Batch sinnvoll definieren. + +#### Typisierte Ausgabeschemas · Aufwand M +Rückgabewerte als Pydantic-Modelle statt `dict[str, Any]`, damit Clients ein präzises `outputSchema` erhalten. +- **Nutzen:** Clients und Agenten können Ergebnisse maschinell sicher weiterverarbeiten. + +#### Signierte Requests (HMAC) · Aufwand S–M +Für Dienste: Signatur über Zeitstempel + Body mit einem gemeinsamen Geheimnis (`X-Signature`, `X-Timestamp`), +Schutz vor Replay durch ein Zeitfenster. Als weitere Methode in der Auth-Kette (`auth: "hmac"`). +Aktuell nicht benötigt; Token + Netz-Einschränkung decken den Fall ab. + +#### Worker-Metriken · Aufwand S +RSS und Anzahl erledigter Aufgaben je Worker als Metrik (heute enthalten die `process_*`-Metriken nur den Server-Prozess). +- **Ansatz:** Worker melden Kennzahlen mit dem Ergebnis zurück, oder der Server liest `/proc//status` der Worker. + +### Niedrige Priorität + +#### Plots · Aufwand M +Funktionsgraphen (z. B. zur Kurvendiskussion) als PNG/SVG im MCP-Bildformat. +- **Achtung:** neue Abhängigkeit (matplotlib, ~+40 MB je Worker); nicht jeder Client zeigt Bilder an. + +#### LaTeX-Eingabe · Aufwand S +Ausdrücke in LaTeX-Notation akzeptieren (`\frac{1}{2}`, `\int_0^1`). +- **Ansatz:** `sympy.parsing.latex.parse_latex`; braucht `antlr4-python3-runtime` in passender Version. Sicherheitsprüfung wie beim bestehenden Parser. + +#### MCP-Prompts · Aufwand S +Vorlagen wie „Kurvendiskussion“, „Statistik-Report“, „Einheiten-Check“ als MCP-Prompts, die Clients im Menü anbieten. + +#### Komplexe Zahlen in Polarform · Aufwand S +Ergebnisse zusätzlich als Betrag/Winkel und in Exponentialform ausgeben. + +--- + +## Qualität und Betrieb + +| Thema | Priorität | Aufwand | Details | +|---|---|---|---| +| CI-Pipeline | hoch | S | `python -m unittest`, `ruff check`, `pip-audit -r requirements.lock` bei jedem Push | +| Regelmäßige Abhängigkeits-Updates | mittel | S | z. B. monatlich `uv lock --upgrade`, Tests, beide Lock-Dateien committen; Renovate/Dependabot möglich | +| Lasttest | mittel | S–M | Durchsatz und Latenz je Worker-Zahl messen, Grundlage für Worker- und Replica-Anzahl | +| Weitere Python-Versionen | niedrig | S | Tests zusätzlich mit 3.13 (lokal ist z. B. `UV_PYTHON=3.13` bereits verbreitet) | + +--- + +## Erledigt + +| Version | Thema | +|---|---| +| 1.2.0 | Client-Verwaltung: Client-Datei auf RWX-PVC mit Hot-Reload, Token-Hashes, Token- und Netz-Clients, Ablaufdatum, Sperren, Limits pro Client | +| 1.2.0 | CLI `mcp-calculator-admin` und Admin-API (eigener Port, Admin-Tokens), Audit-Log | +| 1.2.0 | Überlastschutz: Limit pro Client, Wartezeit-Limit in der Queue, Abbruch bei Verbindungsende, Verbindungslimit; Client-Label in Metriken und Logs | +| 1.1.0 | Backpressure (`MCP_CALC_MAX_QUEUE`), sofortige „busy“-Ablehnung | +| 1.1.0 | Prometheus-Metriken unter `/metrics` | +| 1.1.0 | Graceful Shutdown: Worker-Pool wird bei SIGTERM geordnet gestoppt (behebt die Semaphore-Warnung) | +| 1.1.0 | Vorgewärmte Worker, Readiness erst nach dem Laden der Bibliotheken | +| 1.1.0 | Abhängigkeiten festgeschrieben (`uv.lock`, `requirements.lock` mit Hashes), Python ≥ 3.12 | +| 1.1.0 | Tests auf `unittest` umgestellt | +| 1.0.x | Speicheroptimierung (Server ohne Mathe-Bibliotheken, Worker ohne Server-Stack), siehe [docs/memory.md](docs/memory.md) | +| 1.0.0 | 13 Tools, stdio + Streamable HTTP, sicherer Parser, Worker-Pool mit Timeout und Speicherlimit | diff --git a/README.md b/README.md new file mode 100644 index 0000000..e82c0f6 --- /dev/null +++ b/README.md @@ -0,0 +1,523 @@ +# mcp-calculator + +MCP-Server, der LLMs **exakte, deterministische Mathematik** gibt. LLMs sagen Tokens voraus und +verrechnen sich deshalb bei großen Zahlen, vielen Rechenschritten, Wahrscheinlichkeiten oder +Einheiten. Dieser Server übernimmt das Rechnen. Das LLM formuliert nur den Aufruf und deutet das Ergebnis. + +- **13 Tools**: Arithmetik mit beliebiger Präzision, Algebra, Gleichungen, Analysis, Kurvendiskussion, + Differentialgleichungen, lineare Algebra, Statistik, Regression, Verteilungen, Hypothesentests, + Zahlentheorie, Einheiten +- **Beide Transporte**: `stdio` (lokal) und **Streamable HTTP** (zentral, zustandslos, skalierbar) +- **Sicher**: Parser mit Whitelist statt `eval`, Worker-Prozesse mit hartem Timeout und + Speicherlimit +- **Client-Verwaltung**: API-Tokens (nur als Hash gespeichert) und Netz-Freigaben in einer Datei auf dem + gemeinsamen Volume, änderbar zur Laufzeit per CLI oder Admin-API, mit Audit-Log +- **Überlastschutz**: Verbindungslimit, Limit pro Client, Kapazitätsgrenze, Wartezeit-Limit, Timeout, + Abbruch bei Verbindungsende +- **Betriebsbereit**: Health/Readiness, Prometheus-Metriken, Graceful Shutdown, JSON-Logging, + Konfiguration komplett über Env-Variablen, festgeschriebene Abhängigkeiten + +Stack: Python ≥ 3.12, [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) 2.x, +SymPy, mpmath, NumPy, SciPy, Pint, Pebble, prometheus-client. Tests mit `unittest` (Standardbibliothek). + +Offene Ideen und geplante Features: [BACKLOG.md](BACKLOG.md). + +--- + +## Inhalt + +1. [Schnellstart](#schnellstart) +2. [Tools im Überblick](#tools-im-überblick) +3. [Transporte](#transporte) +4. [Clients anbinden](#clients-anbinden) +5. [Konfiguration](#konfiguration) +6. [Sicherheit](#sicherheit) +7. [Betrieb](#betrieb) +8. [Monitoring](#monitoring) +9. [Architektur](#architektur) +10. [Entwicklung](#entwicklung) +11. [Grenzen](#grenzen) + +Die ausführliche Referenz aller Parameter steht in [docs/tools.md](docs/tools.md). + +--- + +## Schnellstart + +Alles wird ausschließlich im virtuellen Environment des Projekts installiert, exakt in den Versionen +aus dem Lockfile. + +```bash +# venv anlegen und exakt nach uv.lock installieren (inkl. Dev-Werkzeug ruff) +uv sync --frozen --extra dev --python 3.12 +source .venv/bin/activate + +# alternativ ohne uv, mit pip (Hashes werden geprüft) +python3.12 -m venv .venv && source .venv/bin/activate +pip install --require-hashes -r requirements.lock +pip install --no-deps -e . + +# Tests (unittest aus der Standardbibliothek) +python -m unittest # alle Tests (~50 s) +MCP_CALC_SKIP_SLOW=1 python -m unittest # ohne Subprozess-/Server-Tests (~1 s) + +# Server starten +mcp-calculator # stdio (Standard) +mcp-calculator --transport http --port 8080 # HTTP auf 127.0.0.1:8080/mcp +python -m mcp_calculator --help # alle Optionen +``` + +Ausprobieren: + +```bash +# Beispiel-Client (startet den Server selbst über stdio) +python examples/client_example.py + +# gegen einen laufenden HTTP-Server +python examples/client_example.py http://localhost:8080/mcp [TOKEN] + +# interaktiv im Browser mit dem MCP Inspector (braucht Node.js) +npx @modelcontextprotocol/inspector .venv/bin/mcp-calculator +``` + +--- + +## Tools im Überblick + +| Tool | Zweck | Beispiel | +|---|---|---| +| `evaluate` | Wert exakt und dezimal, beliebige Präzision | `1000*(1+0.05)^10` → `1628.89462678` | +| `transform` | simplify, expand, factor, apart, substitute, nsimplify … | `x^2-5x+6` → `(x-3)(x-2)` | +| `solve` | Gleichungen, Systeme, Ungleichungen, numerisch | `["x+y=10","x-y=2"]` → `x=6, y=4` | +| `calculus` | Ableitung, Integral, Grenzwert, Reihe, Summe, Gradient, Hesse, Jacobi | `∫e^(-x²)` von -∞ bis ∞ → `sqrt(pi)` | +| `analyze_function` | komplette Kurvendiskussion | Nullstellen, Extrema, Wendepunkte, Asymptoten … | +| `solve_ode` | Differentialgleichungen mit Anfangswerten | `y''+y=0, y(0)=0, y'(0)=1` → `sin(x)` | +| `matrix` | Determinante, Inverse, Eigenwerte, LGS, SVD … (exakt oder numerisch) | `[[1,2],[3,4]]` → det `-2` | +| `statistics` | deskriptive Statistik, Korrelation | Mittel, Median, Quartile, Schiefe … | +| `regression` | linear, polynomial, exponentiell, logarithmisch, Potenz | Koeffizienten, R², Vorhersage | +| `probability` | 14 Verteilungen: pdf/cdf/sf/Quantil/Intervall | `P(85 < X ≤ 115)` bei N(100, 15) | +| `hypothesis_test` | t-Tests, ANOVA, χ², Mann-Whitney, Wilcoxon, Shapiro, Binomial | p-Wert und Entscheidung im Klartext | +| `number_theory` | Primzahlen, ggT/kgV, Modular, CRT, Kombinatorik, Basen | `factorize 360` → `2^3 * 3^2 * 5` | +| `convert_units` | Einheiten umrechnen und mit Einheiten rechnen | `100 km/h` → `27.78 m/s` | + +**Designentscheidungen, die die Treffsicherheit des LLMs erhöhen:** + +- *Wenige Tools, klar abgegrenzt.* Verwandte Operationen hängen an einem `operation`-Enum. Das LLM wählt zuverlässiger aus 13 Tools als aus 60. +- *Server-`instructions`* erklären beim Verbindungsaufbau, welches Tool wofür gedacht ist, und beschreiben die Syntax. +- *Verkettbare Ergebnisse*: `result` hat dieselbe Syntax wie die Eingabe. Eine Ableitung lässt sich also direkt an `solve` weitergeben. +- *Exakt statt Gleitkomma*: `0.1 + 0.2 = 3/10`. Dezimalwerte kommen als String mit der gewünschten Stellenzahl zurück. +- *Selbsterklärende Ergebnisse*: `meaning` bei Wahrscheinlichkeiten (`P(X > 7) = P(X >= 8)`), `conclusion` bei Tests, `note` bei Sonderfällen. +- *Hilfreiche Fehler*: Tool-Fehler mit konkretem Hinweis (`matrix is singular …; try 'pseudo_inverse'`), damit das LLM den Aufruf selbst korrigieren kann. + +--- + +## Transporte + +| | stdio | Streamable HTTP | +|---|---|---| +| Start | Client startet den Prozess | Server läuft dauerhaft | +| Einsatz | lokal, Einzelplatz, Entwicklung | zentral fürs Team, Pod | +| Auth | keine | API-Token pro Client oder Netz-Freigabe, siehe [docs/auth.md](docs/auth.md) | +| Endpoint | – | `POST http://:8080/mcp` | + +Der HTTP-Transport läuft **zustandslos** (`stateless_http`, JSON-Antworten). Es gibt keine +MCP-Sessions, deshalb braucht es keine Sticky Sessions, und beliebig viele Replicas sind möglich. + +```bash +# Client anlegen (Token wird einmalig angezeigt) +mcp-calculator-admin --file ./data/clients.json create mein-client + +# HTTP, wie im Container +MCP_CALC_TRANSPORT=http \ +MCP_CALC_HOST=0.0.0.0 \ +MCP_CALC_CLIENTS_FILE=./data/clients.json \ +MCP_CALC_LOG_FORMAT=json \ +mcp-calculator +``` + +Rohtest mit curl: + +```bash +curl -s -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer mcpc_…" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"evaluate","arguments":{"expression":"2^100"}}}' +``` + +--- + +## Clients anbinden + +> Die Konfigurationsformate der Clients ändern sich gelegentlich. Die Beispiele entsprechen dem +> Stand bei der Erstellung. Bitte gegen die Doku der eingesetzten Client-Version prüfen. + +Jeder Client (Team, IDE-Installation, Dienst) bekommt ein **eigenes Token**: +`mcp-calculator-admin create `. Das Token wird im Header `Authorization: Bearer ` +mitgeschickt, alternativ als `X-API-Key: `. Verwaltung und Sperren: [docs/auth.md](docs/auth.md). + +### opencode + +`opencode.json` (Projekt) oder `~/.config/opencode/opencode.json` (global): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "calculator": { + "type": "remote", + "url": "https://calculator.intern.example/mcp", + "enabled": true, + "headers": { + "Authorization": "Bearer {env:MCP_CALC_TOKEN}" + } + } + } +} +``` + +Lokal über stdio: + +```json +{ + "mcp": { + "calculator": { + "type": "local", + "command": ["/pfad/zu/mcp-calculator/.venv/bin/mcp-calculator"], + "enabled": true + } + } +} +``` + +### Cline + +`cline_mcp_settings.json` (in Cline über „MCP Servers → Configure“ erreichbar): + +```json +{ + "mcpServers": { + "calculator": { + "type": "streamableHttp", + "url": "https://calculator.intern.example/mcp", + "headers": { + "Authorization": "Bearer " + }, + "disabled": false, + "autoApprove": [ + "evaluate", "transform", "solve", "calculus", "analyze_function", "solve_ode", "matrix", + "statistics", "regression", "probability", "hypothesis_test", "number_theory", "convert_units" + ] + } + } +} +``` + +Alle Tools sind rein lesend, deshalb ist `autoApprove` unbedenklich. Lokal über stdio: + +```json +{ + "mcpServers": { + "calculator": { + "command": "/pfad/zu/mcp-calculator/.venv/bin/mcp-calculator", + "args": [] + } + } +} +``` + +### Claude Code + +```bash +claude mcp add --transport http calculator https://calculator.intern.example/mcp \ + --header "Authorization: Bearer " +# oder lokal +claude mcp add calculator -- /pfad/zu/mcp-calculator/.venv/bin/mcp-calculator +``` + +### Eigene Tools / Agenten + +Jede Anwendung mit MCP-Client-SDK (Python, TypeScript, Java, Go …) kann den Server nutzen. +Ein lauffähiges Python-Beispiel liegt in [`examples/client_example.py`](examples/client_example.py): + +```python +import httpx2 +from mcp import Client +from mcp.client.streamable_http import streamable_http_client + +async with httpx2.AsyncClient(headers={"Authorization": f"Bearer {token}"}) as http: + async with Client(streamable_http_client("https://calculator.intern.example/mcp", http_client=http)) as client: + tools = (await client.list_tools()).tools # -> als Tool-Definitionen ans LLM + result = await client.call_tool("solve", {"equations": "x^2 = 2", "domain": "real"}) + print(result.structured_content) +``` + +Einbindung in eine eigene LLM-Schleife: +1. `list_tools()` ausführen und `name`, `description` und `input_schema` jedes Tools als Tool-Definition an das Modell geben. +2. Fordert das Modell einen Tool-Call an, `call_tool(name, arguments)` aufrufen. +3. `result.content[0].text` zurückgeben. Bei `result.is_error` den Rückgabewert als Fehler markieren. + +> **Hinweis:** Serverseitige MCP-Connectoren von LLM-Anbietern rufen den MCP-Server aus der Cloud +> des Anbieters auf. Ein nur intern erreichbarer Server funktioniert damit nicht. Bei internem +> Betrieb muss der eigene Code MCP-Client sein (wie oben). + +--- + +## Konfiguration + +Jede Option ist per Umgebungsvariable `MCP_CALC_` oder per CLI-Flag `--` setzbar. +Die CLI hat Vorrang vor der Umgebung, die Umgebung vor den Defaults. Eine Vorlage liegt in +[`.env.example`](.env.example). Die Datei wird **nicht** automatisch geladen, sie dient als +Referenz für die Pod-Umgebung. + +| Variable | Default | Beschreibung | +|---|---|---| +| `MCP_CALC_TRANSPORT` | `stdio` | `stdio` oder `http` | +| `MCP_CALC_HOST` | `127.0.0.1` | Bind-Adresse; im Container `0.0.0.0` | +| `MCP_CALC_PORT` | `8080` | HTTP-Port | +| `MCP_CALC_PATH` | `/mcp` | Pfad des MCP-Endpoints | +| `MCP_CALC_ALLOWED_HOSTS` | – | erlaubte `Host`-Header, kommagetrennt; aktiviert den DNS-Rebinding-Schutz | +| `MCP_CALC_ALLOWED_ORIGINS` | – | erlaubte `Origin`-Header (nur für Browser-Clients relevant) | +| `MCP_CALC_TRUSTED_PROXIES` | – | IPs/CIDRs von Proxys (OpenShift-Router), deren `X-Forwarded-For` ausgewertet wird | +| `MCP_CALC_MAX_REQUEST_BODY_BYTES` | `1048576` | maximale Request-Größe | +| `MCP_CALC_MAX_CONNECTIONS` | `200` | gleichzeitige HTTP-Verbindungen; darüber antwortet uvicorn mit `503` | +| `MCP_CALC_METRICS` | `true` | Prometheus-Metriken unter `/metrics` (nur HTTP) | +| **Authentifizierung** | | *nur HTTP; Details in [docs/auth.md](docs/auth.md)* | +| `MCP_CALC_CLIENTS_FILE` | – | Client-Datei (z. B. `/data/clients.json` auf RWX-PVC); aktiviert die Authentifizierung | +| `MCP_CALC_CLIENTS_RELOAD_SECONDS` | `5` | wie oft die Datei auf Änderungen geprüft wird | +| `MCP_CALC_AUTH_TOKENS` | – | statische Tokens `name:token,…` (einfache Alternative, Änderung nur per Neustart) | +| `MCP_CALC_AUDIT_FILE` | `audit.log` neben der Client-Datei | Änderungsprotokoll | +| `MCP_CALC_ADMIN_TOKENS` | – | Admin-Tokens `name:token,…`; nur wenn gesetzt, startet die Admin-API | +| `MCP_CALC_ADMIN_HOST` | `127.0.0.1` | Bind-Adresse der Admin-API (Default: nur per `oc port-forward`/`exec`) | +| `MCP_CALC_ADMIN_PORT` | `9090` | Port der Admin-API | +| `MCP_CALC_EXECUTOR` | `process` | `process` (isolierter Worker-Pool) oder `inline` (nur Entwicklung) | +| `MCP_CALC_WORKERS` | `2` | Anzahl Worker-Prozesse (≈ CPU-Kerne) | +| `MCP_CALC_MAX_QUEUE` | `20` | so viele Berechnungen dürfen auf einen freien Worker warten; darüber wird sofort mit „busy“ abgelehnt (0 = keine Warteschlange) | +| `MCP_CALC_MAX_QUEUE_WAIT_SECONDS` | `30` | maximale Wartezeit auf einen freien Worker, danach „busy“ | +| `MCP_CALC_CLIENT_MAX_CONCURRENT` | `10` | gleichzeitige Berechnungen pro Client (0 = unbegrenzt); pro Client überschreibbar | +| `MCP_CALC_TIMEOUT_SECONDS` | `10` | harter Timeout pro Berechnung | +| `MCP_CALC_MAX_TASKS_PER_WORKER` | `200` | Worker nach N Aufgaben recyceln (0 = nie) | +| `MCP_CALC_MEMORY_LIMIT_MB` | `512` | zusätzlicher Speicher, den eine Berechnung pro Worker belegen darf (nur Linux; 0 = aus), siehe [docs/memory.md](docs/memory.md) | +| `MCP_CALC_MAX_INPUT_LENGTH` | `5000` | max. Zeichen pro Ausdruck | +| `MCP_CALC_MAX_OUTPUT_LENGTH` | `20000` | längere Ergebnisse werden gekürzt | +| `MCP_CALC_MAX_PRECISION` | `1000` | max. signifikante Stellen | +| `MCP_CALC_MAX_MATRIX_SIZE` | `30` | max. Zeilen/Spalten | +| `MCP_CALC_MAX_DATA_POINTS` | `1000000` | max. Werte pro Datensatz | +| `MCP_CALC_MAX_LIST_ITEMS` | `10000` | max. Einträge sonstiger Listen | +| `MCP_CALC_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | +| `MCP_CALC_LOG_FORMAT` | `text` | `text` oder `json` (eine Zeile pro Eintrag) | + +--- + +## Sicherheit + +**Ausdrucks-Parser (`ops/parsing.py`).** SymPys `parse_expr` nutzt intern `eval`. Abgesichert wird das in drei Schichten: + +1. Die Eingabe wird vorab geprüft: Zeichen-Whitelist (keine Quotes, `[]`, `{}`, `;`, `:`, `\`), + kein `__`, kein Attributzugriff (`.name`), keine Python-Keywords, keine Namen mit `_`. +2. Ausgewertet wird in einem Namensraum, der **nur** freigegebene SymPy-Funktionen und ein leeres + `__builtins__` enthält. Unbekannte Namen werden zu mathematischen Symbolen, nie zu Python-Objekten. +3. Die Berechnung läuft isoliert in einem Worker-Prozess (siehe unten). + +Die Tests in `tests/test_parsing.py` decken typische Injection-Versuche ab (`__import__`, `().__class__`, +`lambda`, `open(...)`, `getattr`, …). + +**Worker-Pool (`executor.py`).** Jede Berechnung läuft in einem separaten Prozess (Pebble-Pool, `spawn`): +- Wird der Timeout überschritten, wird der Worker hart beendet und ersetzt. `9^9^9^9` blockiert also nichts. +- Unter Linux darf eine Berechnung pro Worker höchstens `MCP_CALC_MEMORY_LIMIT_MB` zusätzlich belegen (`RLIMIT_AS`, gesetzt nach dem Laden der Bibliotheken). Ein Ausreißer endet mit `MemoryError`, statt den Pod zu killen. +- BLAS/OpenMP laufen pro Worker einthreadig. Die Parallelität kommt aus der Worker-Anzahl, das CPU-Limit des Pods wird nicht überbucht. +- Worker werden nach N Aufgaben recycelt, damit die SymPy-Caches nicht unbegrenzt wachsen. +- Ist der Pool defekt, startet er sich selbst neu. + +**HTTP.** +- Authentifizierung pro Client (API-Token oder Netz-Freigabe). Tokens sind 256 Bit zufällig und werden nur als SHA-256-Hash gespeichert; Sperren, Ablaufdatum, Netz-Einschränkung und Rotation sind zur Laufzeit möglich. Details: **[docs/auth.md](docs/auth.md)**. +- Die Admin-API läuft auf eigenem Port, standardmäßig nur auf `127.0.0.1`, mit eigenen Admin-Tokens; jede Änderung landet im Audit-Log. +- `/healthz`, `/readyz` und `/metrics` bleiben offen für Probes und Prometheus. +- Request-Body-Limit (Default 1 MiB), dazu Längenlimits für alle Eingaben. +- DNS-Rebinding-Schutz: bei `127.0.0.1` automatisch aktiv, bei `0.0.0.0` über `MCP_CALC_ALLOWED_HOSTS` einschaltbar. +- Ohne Client-Konfiguration und nicht an localhost gebunden, protokolliert der Server beim Start eine Warnung. + +Die Einheiten-Umrechnung (Pint) nutzt einen eigenen Tokenizer ohne `eval`. + +--- + +## Betrieb + +Die Anbindung an OpenShift ist nicht Teil dieses Projekts. Für die eigene Integration relevant: + +| Punkt | Wert | +|---|---| +| Start | `mcp-calculator` (Entry Point) oder `python -m mcp_calculator` | +| Pflicht-Env im Container | `MCP_CALC_TRANSPORT=http`, `MCP_CALC_HOST=0.0.0.0` | +| Ports | `8080` MCP; `9090` Admin-API (nur mit `MCP_CALC_ADMIN_TOKENS`, Default nur `127.0.0.1`). Unprivilegiert, beliebige UID möglich | +| Volume | RWX-PVC z. B. unter `/data` für `clients.json` und `audit.log`, sonst wird nichts geschrieben | +| Liveness | `GET /healthz` → `200 {"status":"ok","version":…}` | +| Readiness | `GET /readyz` → `200` erst, wenn die Worker gestartet sind und ihre Bibliotheken geladen haben, sonst `503` | +| Metriken | `GET /metrics` (Prometheus, ohne Token), siehe [Monitoring](#monitoring) | +| Shutdown | `SIGTERM`: keine neuen Verbindungen, laufende Requests werden fertig gerechnet, danach wird der Worker-Pool geordnet gestoppt (Log: `worker pool stopped`) | +| Überlast | Sechs Schutzschichten (Verbindungen, pro Client, Kapazität, Wartezeit, Timeout, Abbruch bei Verbindungsende); Ablehnungen sind Tool-Fehler mit „retry“-Hinweis. Siehe [docs/auth.md → Überlastschutz](docs/auth.md#überlastschutz) | +| Skalierung | zustandslos, horizontal skalierbar, keine Sticky Sessions | +| Ressourcen | `MCP_CALC_WORKERS` ≈ CPU-Limit. Leerlauf gemessen (macOS, RSS): ~90 MB + ~140 MB je Worker, mit 2 Workern ~360 MB. Pod-Limit ≥ Leerlauf + `WORKERS` × `MEMORY_LIMIT_MB`. Analyse, Profile und Messskript: **[docs/memory.md](docs/memory.md)** | +| Logs | stderr; `MCP_CALC_LOG_FORMAT=json` für Log-Aggregation. Pro Tool-Aufruf: `tool= client= status=… duration_ms=…`; abgelehnte Anfragen: `auth rejected: reason=… client=… ip=…` | +| Startzeit | `/readyz` nach ~1,2 s (Worker vorgewärmt); der erste Aufruf ist danach sofort schnell | + +--- + +## Monitoring + +`GET /metrics` liefert Prometheus-Textformat. Wie `/healthz` und `/readyz` ist der Endpoint ohne Token +erreichbar, damit Prometheus im Cluster scrapen kann. Er enthält Zähler und Client-Namen, keine Rechen-Eingaben. +Wer das nicht möchte, schränkt den Zugriff per NetworkPolicy ein oder setzt `MCP_CALC_METRICS=false`. + +| Metrik | Typ | Bedeutung | +|---|---|---| +| `mcp_calc_tool_calls_total{tool, client, status}` | Counter | Aufrufe je Tool, Client und Ergebnis: `ok`, `error` (Mathe-/Eingabefehler), `timeout`, `busy` (Kapazität, Wartezeit oder Client-Limit), `cancelled` (Client hat Verbindung getrennt), `crashed` (Worker gestorben). `client` ist `local` bei stdio, `anonymous` ohne Client-Konfiguration | +| `mcp_calc_tool_duration_seconds{tool}` | Histogram | Dauer inkl. Wartezeit in der Queue | +| `mcp_calc_inflight_computations` | Gauge | laufende + wartende Berechnungen | +| `mcp_calc_capacity` | Gauge | `WORKERS + MAX_QUEUE`; darüber wird abgelehnt | +| `mcp_calc_workers` | Gauge | konfigurierte Worker | +| `mcp_calc_pool_restarts_total` | Counter | Neustarts des Pools nach internem Fehler | +| `mcp_calc_client_inflight_computations{client}` | Gauge | laufende + wartende Berechnungen je Client | +| `mcp_calc_auth_failures_total{reason}` | Counter | abgelehnte Anfragen: `missing_credentials`, `invalid_token`, `disabled`, `expired`, `network_denied` | +| `mcp_calc_clients{status}` | Gauge | konfigurierte Clients: `active`, `disabled`, `expired` | +| `mcp_calc_build_info{version}` | Info | Version | +| `process_*`, `python_*` | Standard | CPU, RSS, Dateien des **Server**-Prozesses (Worker nicht enthalten) | + +Nicht gezählt werden Aufrufe, die schon an der Schema-Validierung scheitern (falscher Parametertyp). +Die beantwortet das MCP SDK, bevor der Tool-Code läuft. + +Nützliche Abfragen: + +```promql +# Fehler- und Timeout-Quote je Tool (5 min) +sum by (tool, status) (rate(mcp_calc_tool_calls_total{status!="ok"}[5m])) + / ignoring(status) group_left sum by (tool) (rate(mcp_calc_tool_calls_total[5m])) + +# p95-Dauer je Tool +histogram_quantile(0.95, sum by (tool, le) (rate(mcp_calc_tool_duration_seconds_bucket[5m]))) + +# Auslastung (Alarm z. B. > 0.8 über 5 min → mehr Worker oder Replicas) +mcp_calc_inflight_computations / mcp_calc_capacity + +# Überlast-Ablehnungen, nach Client +sum by (client) (rate(mcp_calc_tool_calls_total{status="busy"}[5m])) + +# Wer belegt gerade die Kapazität? +topk(5, mcp_calc_client_inflight_computations) + +# Auffällige Auth-Fehler (falsch konfigurierte Clients oder Rateversuche) +sum by (reason) (rate(mcp_calc_auth_failures_total[5m])) +``` + +--- + +## Architektur + +``` + ┌────────────────────────── Server-Prozess ──────────────────────────┐ + Client ─stdio──►│ MCPServer (server.py) │ + ─HTTP───►│ └ Starlette/uvicorn (http_app.py): Bearer-Auth, /healthz, /readyz │ + │ Tool-Funktionen: Validierung (Pydantic-Schemas) → Executor │ + └───────────────────────────────┬─────────────────────────────────────┘ + │ Task-Name + Argumente, Timeout + ┌──────────────────▼──────────────────┐ + │ Worker-Pool (executor.py, Pebble) │ + │ ops/*.py – reine Mathe-Funktionen │ + │ SymPy · mpmath · NumPy · SciPy · Pint + └─────────────────────────────────────┘ +``` + +Der Server-Prozess lädt nur MCP SDK und HTTP-Stack, die Worker nur die Mathe-Bibliotheken. +Das halbiert den Speicherbedarf ungefähr (Details in [docs/memory.md](docs/memory.md)) und wird von +`tests/test_footprint.py` abgesichert. + +``` +src/mcp_calculator/ +├── __main__.py Entry Point: Konfiguration laden, Logging, Transport starten +├── config.py Settings aus Env/CLI, Validierung, Limits +├── server.py MCP-Tools: Schemas, Beschreibungen, Server-Instructions +├── http_app.py HTTP-Transport: ASGI-App, Abbruch bei Verbindungsende, Health, Admin-Server +├── auth.py Authentifizierung (Token, Netz) und Client-Identität je Request +├── clients.py Client-Modell, Client-Datei (Lock, atomar), Hot-Reload +├── admin.py Client-Verwaltung (gemeinsame Logik), Audit-Log, Admin-API +├── admin_cli.py CLI mcp-calculator-admin +├── ratelimit.py Limit gleichzeitiger Berechnungen pro Client +├── executor.py Worker-Pool: Timeout, Speicherlimit, Backpressure, Vorwärmen, Selbstheilung +├── metrics.py Prometheus-Metriken +├── logging_setup.py Text-/JSON-Logging nach stderr +└── ops/ reine, synchrone Mathe-Funktionen (dict rein, dict raus) + ├── spec.py Typen/Enums/Hilfetexte für die Tool-Schemas (import-leicht, auch im Server) + ├── context.py Fehlertyp und Limits (import-leicht) + ├── parsing.py sicherer Ausdrucks-Parser + ├── common.py Ergebnis-Formatierung, Limit-Prüfungen + ├── algebra.py evaluate, transform, solve + ├── calculus.py calculus, analyze_function, solve_ode + ├── linalg.py matrix + ├── stats.py statistics, regression, probability, hypothesis_test + ├── number_theory.py + └── units.py +tests/ unittest: pro Modul + End-to-End (in-process, HTTP, stdio, Pool, Footprint) +examples/ lauffähiger MCP-Client +scripts/ measure_memory.py – Speicher messen +docs/tools.md vollständige Tool-Referenz +docs/memory.md Speicheranalyse und Dimensionierung +docs/auth.md Authentifizierung, Client-Verwaltung, Überlastschutz (Betriebshandbuch) +uv.lock Lockfile (uv), Quelle der Wahrheit für Versionen +requirements.lock daraus exportiert, mit Hashes, für pip/Container-Builds +BACKLOG.md geplante Features und offene Themen +``` + +### Neues Tool hinzufügen + +1. Eine reine Funktion in `ops/.py` schreiben: Parameter rein, JSON-fähiges `dict` raus. + Erwartbare Fehler als `MathError("verständliche Meldung")` werfen. Bei einem neuen Modul dieses in + `executor.TASK_MODULES` eintragen. +2. Enums/Literal-Typen für die Parameter in `ops/spec.py` definieren, **nicht** im Rechenmodul. + `server.py` darf nur `spec` importieren, sonst landen SymPy/SciPy wieder im Server-Prozess + (`tests/test_footprint.py` schlägt dann fehl). +3. In `server.py` ein `@mcp.tool(annotations=READ_ONLY)` mit typisierten, per `Field(description=…)` + beschriebenen Parametern anlegen, das `await run("", ".", **kwargs)` aufruft. +4. Das Tool in `INSTRUCTIONS` (Abschnitt „Which tool“) und in `docs/tools.md` ergänzen. +5. Tests schreiben: Fachlogik direkt in `tests/test_.py`, Aufruf über MCP in `tests/test_server.py` + (dort auch die Liste `EXPECTED_TOOLS` anpassen). + +--- + +## Entwicklung + +```bash +source .venv/bin/activate +python -m unittest # alle Tests +MCP_CALC_SKIP_SLOW=1 python -m unittest # schnelle Tests ohne Subprozesse +python -m unittest tests.test_parsing -v # nur Parser/Sicherheit +python -m unittest tests.test_algebra.SolveTest.test_quadratic # ein einzelner Test +ruff check src tests examples scripts # Linting (Konfiguration in pyproject.toml) +MCP_CALC_EXECUTOR=inline MCP_CALC_LOG_LEVEL=DEBUG mcp-calculator # Debugging ohne Worker-Pool +``` + +**Abhängigkeiten aktualisieren.** `pyproject.toml` enthält die erlaubten Bereiche, `uv.lock` die exakt +getesteten Versionen. Nach einer Aktualisierung immer beide Lock-Dateien committen: + +```bash +uv lock --upgrade # oder gezielt: uv lock --upgrade-package mcp +uv export --frozen --format requirements-txt --no-emit-project --no-header -o requirements.lock +uv sync --frozen --extra dev --python 3.12 && python -m unittest +``` + +> **Hinweis zur Python-Version:** Ist in der Shell `UV_PYTHON` gesetzt (z. B. `UV_PYTHON=3.13`), hat das bei +> uv Vorrang vor `.python-version`. Deshalb steht in den Befehlen oben `--python 3.12` explizit. + +Code, Docstrings und Tool-Beschreibungen sind auf Englisch. Das ist die Sprache, mit der LLMs +Tool-Schemas am zuverlässigsten interpretieren. Die Projektdokumentation ist auf Deutsch. + +--- + +## Grenzen + +- **Formulierung bleibt beim LLM.** Der Server rechnet korrekt, aber das Modell muss die Aufgabe richtig in einen Aufruf übersetzen. Diese Fehlerquelle bleibt. +- **Symbolik hat Grenzen.** Nicht jedes Integral, jede Gleichung und jede DGL hat eine geschlossene Form. Der Server fällt dann auf numerische Verfahren zurück (Integral, Summe) oder gibt einen Hinweis (`numeric_only`). Aufwendige Probleme laufen in den Timeout. +- **Numerisches Lösen** (`numeric_only`) liefert bei nicht-polynomialen Gleichungen eine Wurzel nahe dem Startwert, nicht alle. +- **Speicherlimit pro Worker** greift nur unter Linux. Unter macOS gilt nur der Timeout. Die Speicherwerte in der Doku sind unter macOS gemessen; im Container bitte mit `scripts/measure_memory.py` nachmessen. +- **Keine Plots**, keine Finanzmathematik-Spezialfunktionen (IRR, Annuitäten lassen sich über `evaluate`/`solve` abbilden). Geplant siehe [BACKLOG.md](BACKLOG.md). +- **Warteschlange pro Replica.** `MAX_QUEUE` begrenzt nur den einzelnen Pod; Lastverteilung über mehrere Replicas übernimmt der Service/Router. diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..b62db60 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,248 @@ +# Authentifizierung, Client-Verwaltung und Überlastschutz + +Betriebshandbuch für den HTTP-Betrieb. Lokal über **stdio gibt es keine Authentifizierung** und keine +Limits pro Client, denn dort startet der Nutzer den Prozess selbst. + +- [Überblick](#überblick) +- [Einrichtung im Pod](#einrichtung-im-pod) +- [Clients verwalten](#clients-verwalten) (CLI und Admin-API) +- [Einstellungen pro Client](#einstellungen-pro-client) +- [Netzwerk-Clients und Proxys](#netzwerk-clients-und-proxys) +- [Abläufe: anlegen, sperren, rotieren](#abläufe) +- [Überlastschutz](#überlastschutz) +- [Sicherheitshinweise](#sicherheitshinweise) +- [Ausblick: Keycloak](#ausblick-keycloak) + +--- + +## Überblick + +``` + ┌──────────────────────────── Pod (Replica 1..N) ────────────────────────────┐ + Client ──HTTP──► │ :8080 uvicorn ─► Auth (Token/Netz) ─► Limit pro Client ─► Queue ─► Worker │ + │ :9090 Admin-API (nur 127.0.0.1) │ + └────────────┬──────────────────────────────────────────────────▲────────────┘ + │ schreibt (Lock, atomar) │ liest bei Änderung + ▼ │ (alle 5 s geprüft) + /data/clients.json + /data/audit.log (RWX-PVC, alle Replicas) +``` + +Es gibt drei Betriebsarten: + +| Konfiguration | Verhalten | +|---|---| +| `MCP_CALC_CLIENTS_FILE` gesetzt | **Empfohlen.** Clients aus der Datei, änderbar zur Laufzeit per CLI oder Admin-API | +| nur `MCP_CALC_AUTH_TOKENS=name:token,…` | einfache statische Tokens (z. B. aus einem Secret), Änderung nur per Neustart | +| nichts gesetzt | keine Authentifizierung (Warnung im Log, wenn nicht an localhost gebunden) | + +Beide Quellen lassen sich kombinieren, die Client-Namen müssen dann eindeutig sein. + +Jeder Request wird einem **Client** zugeordnet. Der Name erscheint in Logs (`tool=… client=…`) und +Metriken (`client`-Label). Bei Überlast ist dadurch sofort sichtbar, wer sie verursacht. + +--- + +## Einrichtung im Pod + +| Einstellung | Wert | Zweck | +|---|---|---| +| PVC | RWX, gemountet z. B. unter `/data` | gemeinsame Client-Datei für alle Replicas | +| `MCP_CALC_CLIENTS_FILE` | `/data/clients.json` | aktiviert die Authentifizierung; Datei darf anfangs fehlen (= keine Clients) | +| `MCP_CALC_AUDIT_FILE` | Default `/data/audit.log` | Änderungsprotokoll | +| `MCP_CALC_ADMIN_TOKENS` | `ops:` aus einem **Secret** | aktiviert die Admin-API; mehrere Admins kommagetrennt | +| `MCP_CALC_ADMIN_HOST` / `_PORT` | `127.0.0.1` / `9090` | Admin-API nur im Pod erreichbar (siehe unten) | +| `MCP_CALC_TRUSTED_PROXIES` | IP/CIDR des OpenShift-Routers | nur nötig für Netz-Einschränkungen hinter der Route | + +Admin-Token erzeugen: `python -c "import secrets; print(secrets.token_urlsafe(32))"`. + +**Zugriff auf die Admin-API.** Mit dem Default `127.0.0.1` ist sie nur aus dem Pod heraus erreichbar: + +```bash +oc port-forward deploy/mcp-calculator 9090:9090 # dann lokal: http://localhost:9090/admin/clients +# oder direkt die CLI im Pod: +oc exec deploy/mcp-calculator -- mcp-calculator-admin list +``` + +Soll ein anderer Dienst im Cluster die API nutzen, `MCP_CALC_ADMIN_HOST=0.0.0.0` setzen, einen eigenen +internen Service **ohne Route** anlegen und per NetworkPolicy einschränken. + +**Wartung der Datei.** Alle Replicas lesen `/data/clients.json`. Geschrieben wird unter einer exklusiven +POSIX-Sperre (`fcntl.lockf`, funktioniert auf NFS und CephFS) und atomar per Umbenennen, sodass nie eine +halb geschriebene Datei gelesen wird. Ist die Datei nach einer Änderung ungültig (z. B. Tippfehler bei +Handbearbeitung), **behalten die Replicas den letzten gültigen Stand** und loggen einen Fehler. Beim +Start führt eine ungültige Datei dagegen zum Abbruch. Die Datei gehört ins Backup. + +--- + +## Clients verwalten + +### CLI + +```bash +export MCP_CALC_CLIENTS_FILE=/data/clients.json # im Pod bereits gesetzt + +mcp-calculator-admin create opencode-team-a --max-concurrent 4 --expires 2027-03-31 --note "Team A, IDE" +mcp-calculator-admin create reporting-service --networks 10.130.4.0/24 +mcp-calculator-admin create legacy-batch --network-only --networks 10.131.2.15 +mcp-calculator-admin list +mcp-calculator-admin show opencode-team-a +mcp-calculator-admin disable opencode-team-a # sperren +mcp-calculator-admin enable opencode-team-a +mcp-calculator-admin set opencode-team-a --max-concurrent 8 --expires never --networks none +mcp-calculator-admin rotate opencode-team-a # neues Token, altes sofort ungültig +mcp-calculator-admin delete opencode-team-a --yes +mcp-calculator-admin --json list # maschinenlesbar +``` + +Beispielausgabe von `list`: + +``` +NAME STATUS AUTH LIMIT EXPIRES TOKEN NETWORKS +legacy-batch active network default - - 10.131.2.15/32 +opencode-team-a active token 4 2027-03-31 mcpc_kFD5… - +reporting-service disabled token default - mcpc_x81Q… 10.130.4.0/24 +``` + +### Admin-API + +Alle Aufrufe brauchen `Authorization: Bearer `. + +| Methode | Pfad | Body | Ergebnis | +|---|---|---|---| +| `GET` | `/admin/clients` | – | Liste aller Clients (ohne Hashes) | +| `POST` | `/admin/clients` | `{"name", "auth"?, "max_concurrent"?, "expires"?, "allowed_networks"?, "note"?}` | `201`, Client + **Token (einmalig)** | +| `GET` | `/admin/clients/{name}` | – | ein Client | +| `PATCH` | `/admin/clients/{name}` | `{"enabled"?, "max_concurrent"?, "expires"?, "allowed_networks"?, "note"?}` | geänderter Client | +| `POST` | `/admin/clients/{name}/rotate` | – | Client + **neues Token (einmalig)** | +| `DELETE` | `/admin/clients/{name}` | – | `{"deleted": name}` | +| `GET` | `/healthz` | – | ohne Token | + +Fehler: `400` ungültige Eingabe, `401` fehlendes/falsches Admin-Token, `404` unbekannt, `409` existiert bereits. + +```bash +A="Authorization: Bearer $ADMIN_TOKEN" +curl -s -H "$A" -H 'Content-Type: application/json' -X POST localhost:9090/admin/clients \ + -d '{"name": "agent-reporting", "max_concurrent": 2, "expires": "2027-06-30"}' +curl -s -H "$A" -H 'Content-Type: application/json' -X PATCH localhost:9090/admin/clients/agent-reporting \ + -d '{"enabled": false}' +``` + +Die Replica, die den Admin-Aufruf bearbeitet, übernimmt die Änderung sofort. Alle anderen Replicas +folgen nach spätestens `MCP_CALC_CLIENTS_RELOAD_SECONDS` (Default 5 s). + +### Clients verwenden + +``` +Authorization: Bearer mcpc_… (Standard, z. B. opencode "headers", Cline "headers") +X-API-Key: mcpc_… (Alternative für Dienste, die Authorization nicht setzen können) +``` + +--- + +## Einstellungen pro Client + +| Feld | Default | Bedeutung | +|---|---|---| +| `auth` | `token` | `token` = Token nötig; `network` = nur über die Quell-IP (siehe unten) | +| `enabled` | `true` | `false` sperrt den Client, ohne ihn zu löschen (Antwort `403 client is disabled`) | +| `expires` | – | letzter gültiger Tag (UTC), danach `403 API token has expired` | +| `max_concurrent` | Server-Default | gleichzeitige Berechnungen dieses Clients; `0` = unbegrenzt; nicht gesetzt = `MCP_CALC_CLIENT_MAX_CONCURRENT` | +| `allowed_networks` | – | IPs/CIDRs, aus denen der Client zugreifen darf; leer = überall | +| `note` | – | Freitext (Team, Ansprechpartner, Zweck) | +| `token_hint` | automatisch | die ersten Zeichen des Tokens zur Wiedererkennung (`mcpc_kFD5…`) | +| `created`, `updated` | automatisch | Zeitstempel | + +--- + +## Netzwerk-Clients und Proxys + +Für einen anderen Dienst gibt es zwei Möglichkeiten: + +1. **Token mit Netz-Einschränkung (empfohlen):** `create reporting-service --networks 10.130.4.0/24`. + Das Token gilt nur aus diesem Netz. Ein gestohlenes Token ist von anderswo wertlos. +2. **Nur Netz** (`--network-only`): kein Token, Zugang allein über die Quell-IP. Das ist bequem, aber schwächer: + - In OpenShift wechseln Pod-IPs. Ein Netz muss daher den ganzen Bereich abdecken, aus dem der Dienst kommt. + - Hinter der Route sieht der Server nur die IP des Routers. Die echte Client-IP steht in + `X-Forwarded-For`, und der Server wertet sie **nur** aus, wenn der direkte Absender in + `MCP_CALC_TRUSTED_PROXIES` steht. Sonst könnte jeder den Header fälschen. + - Innerhalb des Clusters (Aufruf über den Service, ohne Route) sieht der Server die echte Pod-IP. + - Ergänzend sollte eine NetworkPolicy den Zugriff auf Namespace- oder Label-Ebene begrenzen. + +Reihenfolge der Prüfung: Wird ein Token mitgeschickt, muss es gültig sein. Ein falsches Token wird +auch aus einem freigegebenen Netz abgelehnt. Nur ohne Token greifen Netzwerk-Clients, bei mehreren +Treffern gewinnt das spezifischste Netz. + +Signierte Header (HMAC) sind vorgesehen, aber nicht umgesetzt, siehe [BACKLOG.md](../BACKLOG.md). + +--- + +## Abläufe + +**Neuer Nutzer oder Dienst:** `create`, das Token sicher übergeben (Passwort-Manager, Secret) und +im Client als Header eintragen. Das Token lässt sich nicht erneut anzeigen. Geht es verloren: `rotate`. + +**Sofort sperren** (z. B. Token geleakt oder Agent läuft Amok): `disable `. Das wirkt auf der +bearbeitenden Replica sofort, auf allen anderen nach spätestens 5 s. Danach `rotate` und das neue +Token ausgeben, zum Schluss `enable`. + +**Regelmäßig rotieren:** `expires` setzen. Vor Ablauf `rotate`, dann `set --expires `. + +**Überblick:** `list` bzw. die Metriken `mcp_calc_clients{status}` und +`mcp_calc_auth_failures_total{reason}`. Häufige `invalid_token`-Fehler deuten auf falsch +konfigurierte Clients oder Rateversuche hin. + +**Audit:** `/data/audit.log`, eine JSON-Zeile pro Änderung, z. B. + +```json +{"time": "2026-09-11T17:30:42+00:00", "actor": "admin:ops", "action": "create", "client": "demo", "details": {"max_concurrent": 2, "token_hint": "mcpc_cv1k"}} +``` + +`actor` ist `admin:` (Admin-API) oder `cli:` (CLI). Tokens und Hashes werden nie protokolliert. + +--- + +## Überlastschutz + +Mehrere Schutzschichten greifen nacheinander. Jede lehnt früh und billig ab, statt Last aufzustauen: + +| # | Schicht | Einstellung (Default) | Wirkung bei Überschreitung | Metrik-Status | +|---|---|---|---|---| +| 1 | Verbindungen | `MCP_CALC_MAX_CONNECTIONS` (200) | HTTP `503` von uvicorn | – | +| 2 | Limit pro Client | `MCP_CALC_CLIENT_MAX_CONCURRENT` (10) bzw. `max_concurrent` | Tool-Fehler „client busy“ | `busy` | +| 3 | Gesamtkapazität | `MCP_CALC_WORKERS` + `MCP_CALC_MAX_QUEUE` (2 + 20) | Tool-Fehler „server busy“ | `busy` | +| 4 | Wartezeit in der Queue | `MCP_CALC_MAX_QUEUE_WAIT_SECONDS` (30) | Tool-Fehler „no worker became free“ | `busy` | +| 5 | Rechenzeit | `MCP_CALC_TIMEOUT_SECONDS` (10) | Worker wird beendet, Tool-Fehler „timeout“ | `timeout` | +| 6 | Verbindungsabbruch | immer aktiv | wartende Berechnung verworfen, laufende abgebrochen (Worker beendet) | `cancelled` | + +Die Ablehnungen der Schichten 2–5 sind **Tool-Fehler mit Hinweis „retry“**. Das LLM bzw. der Agent +sieht, dass es später erneut versuchen soll. Schicht 6 verhindert, dass Berechnungen weiterlaufen, +auf deren Ergebnis niemand mehr wartet. + +Alle Zähler gelten **pro Replica**. Das passt zum Ziel Überlastschutz: Jede Replica schützt sich +selbst, ein gemeinsamer Zähler ist nicht nötig. Mit mehr Replicas steigt die Gesamtkapazität linear. + +**Hinweise zur Dimensionierung:** +- `CLIENT_MAX_CONCURRENT` kleiner als `WORKERS + MAX_QUEUE` wählen, sonst kann ein einzelner Client alles belegen. +- `MAX_QUEUE_WAIT_SECONDS` unter dem Timeout der Clients halten, sonst warten Anfragen, die niemand mehr abholt. Die Schicht 6 fängt das zusätzlich ab. +- Alarm z. B. auf `mcp_calc_inflight_computations / mcp_calc_capacity > 0.8` oder auf `rate(mcp_calc_tool_calls_total{status="busy"}[5m]) > 0`, siehe README, Abschnitt Monitoring. +- Lastspitzen einzelner Clients erkennt man an `mcp_calc_client_inflight_computations{client}` und `mcp_calc_tool_calls_total{client, status="busy"}`. + +--- + +## Sicherheitshinweise + +- **Nur Hashes gespeichert:** Wer die Datei lesen kann, kann damit nicht zugreifen, aber Clients anlegen oder entsperren, wenn er schreiben kann. PVC-Zugriff daher auf den Pod beschränken. Die Datei wird mit Rechten `0640` geschrieben. +- **Tokens** sind 256 Bit zufällig (`mcpc_` + 43 Zeichen). Admin-Tokens mindestens ebenso lang wählen (Mindestlänge 16 Zeichen wird erzwungen). +- **Admin-API** nie über eine Route veröffentlichen. Default ist `127.0.0.1`. +- `/healthz`, `/readyz` und `/metrics` sind ohne Token erreichbar. Sie enthalten keine Rechen-Eingaben, `/metrics` aber Client-Namen. Bei Bedarf per NetworkPolicy einschränken oder `MCP_CALC_METRICS=false` setzen. +- Abgelehnte Anfragen werden mit Grund, Client (falls bekannt) und IP geloggt (`auth rejected: reason=…`). + +--- + +## Ausblick: Keycloak + +Geplant ist die Prüfung von Keycloak-JWTs (OIDC) über den `TokenVerifier` des MCP SDK, zusätzlich zu den +API-Tokens. Die Identität aus dem JWT (z. B. `azp`/Client-ID oder ein Claim) wird dann genauso wie +heute ein Client behandelt: gleiche Limits, gleiche Metriken. API-Tokens bleiben für Dienste und +Clients, die kein OIDC können. Offene Frage vor der Umsetzung: ob opencode und Cline den OAuth-Ablauf +von MCP gegen Keycloak unterstützen. Details siehe [BACKLOG.md](../BACKLOG.md). diff --git a/docs/memory.md b/docs/memory.md new file mode 100644 index 0000000..da5d89d --- /dev/null +++ b/docs/memory.md @@ -0,0 +1,154 @@ +# Speicherbedarf + +Analyse, woher der Speicherbedarf kommt, welche Optimierungen umgesetzt sind und wie man den +Pod dimensioniert. Alle Werte sind **RSS**, gemessen unter macOS (arm64, Python 3.12). Unter +Linux im Container liegen sie erfahrungsgemäß in derselben Größenordnung, **messt dort bitte +selbst nach**: + +```bash +python scripts/measure_memory.py imports # Kosten pro Bibliothek +python scripts/measure_memory.py server # kompletter Server, 2 Worker, nach Warm-up +python scripts/measure_memory.py server --workers 4 +``` + +## Kurzfassung + +| Konfiguration | Server | je Worker | Resource-Tracker | **Summe** | +|---|---|---|---|---| +| 1 Worker | 70 MB | 137 MB | 18 MB | **~225 MB** | +| 2 Worker (Default) | 70 MB | 137 MB | 18 MB | **~360 MB** | +| 4 Worker | 70 MB | ~135 MB | 18 MB | **~630 MB** | + +Faustformel: **~90 MB + Anzahl Worker × ~140 MB** im Leerlauf. Dazu kommt Spielraum für laufende +Berechnungen (siehe [Dimensionierung](#dimensionierung)). + +## Woher der Speicher kommt + +Das Rechnen selbst braucht kaum Speicher. Den Großteil belegen die **geladenen Bibliotheken**, +und zwar in jedem Prozess, der sie importiert. Kosten pro Import-Schritt (kumulativ, frischer Prozess): + +| Schritt | RSS gesamt | Zuwachs | Wofür | +|---|---|---|---| +| Python-Interpreter | 17 MB | | Basis | +| + NumPy | 28 MB | +11 | Numerik, Basis für SciPy/Pint | +| + SymPy (inkl. mpmath) | 62 MB | +35 | symbolische Mathematik, Parser | +| + SciPy (`scipy.stats`) | 123 MB | **+60** | Statistik, Verteilungen, Tests; größter Einzelposten | +| + Pint | 126 MB | +4 | Einheiten | +| + MCP SDK, Pydantic | 161 MB | +35 | Protokoll, Schemas | +| + uvicorn, Starlette, Pebble | 162 MB | +0 | HTTP-Server, Pool (von MCP bereits geladen) | + +Dazu kommen zur Laufzeit ~10 MB für das Pint-Einheitenregister und die SymPy-Caches. + +## Prozessmodell + +``` +Server-Prozess MCP-Protokoll, HTTP, Validierung → braucht: MCP SDK, Pydantic, uvicorn + ├─ Worker 1..N die eigentlichen Berechnungen → braucht: NumPy, SymPy, SciPy, Pint + └─ Resource-Tracker Hilfsprozess von multiprocessing → ~18 MB, fix +``` + +Die Worker sind nötig, damit Berechnungen einen harten Timeout und ein Speicherlimit bekommen +(siehe README, Abschnitt Sicherheit). Jeder Worker ist ein vollständiger Python-Prozess mit eigenem Speicher. + +## Umgesetzte Optimierungen + +Vor der Optimierung luden **alle** Prozesse **alle** Bibliotheken (Server 167 MB, je Worker 162 MB, +zusammen ~510 MB bei 2 Workern). Zwei Ursachen, zwei Maßnahmen: + +### 1. Server-Prozess ohne Mathe-Bibliotheken (−97 MB) + +**Ursache:** Der Server übergab den Workern Funktionsreferenzen (`algebra.evaluate`). Pickle +braucht dafür das importierte Modul, also lud der Server SymPy und SciPy, ohne je damit zu rechnen. +Zusätzlich kamen die Enum-Typen der Tool-Schemas aus den Rechenmodulen. + +**Maßnahme:** +- Aufträge gehen **per Name** an die Worker (`executor.run("algebra.evaluate", …)`). Der Worker löst den + Namen selbst auf. Erlaubt sind nur öffentliche Funktionen der Module in `executor.TASK_MODULES`. +- Alle Typen, Enums und Hilfetexte, die der Server braucht, liegen in `ops/spec.py`. Fehlertyp und Limits liegen in + `ops/context.py`. Beide Module importieren keine Mathe-Bibliothek. + +**Ergebnis:** Server 167 → 70 MB. Der Server-Prozess startet dadurch auch schneller (0,3 statt 0,8 s bis zum HTTP-Listen). + +### 2. Worker ohne MCP/HTTP-Stack (−25 MB je Worker) + +**Ursache:** Worker werden mit `spawn` gestartet. Dabei importiert Python in jedem Worker das +Hauptmodul des Elternprozesses neu. Das war `mcp_calculator.__main__`, und das importierte auf +oberster Ebene Server, MCP SDK und uvicorn. + +**Maßnahme:** `__main__.py` importiert Server, HTTP-App und anyio erst innerhalb von `main()`/`_serve()`. + +**Ergebnis:** Worker 162 → 137 MB. Bei 2 Workern ~50 MB, bei 4 Workern ~100 MB weniger. + +### Absicherung + +`tests/test_footprint.py` prüft das automatisch: +- Server-, HTTP- und Executor-Module importieren weder SymPy, NumPy, SciPy, Pint noch mpmath. +- Ein Worker, gestartet wie über das Konsolenskript, hat kein `mcp`, `uvicorn` und `starlette` geladen, + dafür SymPy/SciPy vorgeladen, und läuft mit `OPENBLAS_NUM_THREADS=1`. +- Das Speicherlimit wird relativ zur geladenen Größe berechnet, und ein Fehler beim Setzen legt den Worker nicht lahm. +- Über den Task-Namen lassen sich nur öffentliche Funktionen der Rechenmodule aufrufen. + +Wer später einen Import in `server.py`, `http_app.py`, `executor.py` oder `__main__.py` ergänzt, bekommt +einen roten Test, falls der Import eine Mathe-Bibliothek in den Server oder den Server-Stack in die +Worker zieht. + +### 3. Vorgewärmte Worker + +Pebble startet Worker erst beim ersten Auftrag. Der erste Tool-Aufruf hätte dann ~0,5 s auf das Laden +von SymPy/SciPy gewartet. Der Pool bekommt deshalb beim Start einen leeren Auftrag, und `/readyz` meldet +erst danach „bereit“ (~1,2 s nach Start). Der Pod bekommt also keinen Traffic, bevor er rechnen kann, +und der erste echte Aufruf ist sofort schnell. Speicher kostet das nicht zusätzlich, die Worker wären +ohnehin gestartet. + +## Bewusst nicht umgesetzt + +| Idee | Warum nicht | +|---|---| +| SciPy erst beim ersten Statistik-Aufruf laden | Worker werden wiederverwendet. Nach kurzer Zeit hat jeder Worker SciPy doch geladen, die Ersparnis wäre also nur vorübergehend. Außerdem würde der erste Statistik-Aufruf pro Worker ~0,5 s langsamer. | +| `fork` statt `spawn` (Copy-on-Write) | Worker könnten die vom Parent geladenen Bibliotheken teilen. Forken eines Prozesses mit laufenden Threads (asyncio, uvicorn) ist aber unsicher und ab Python 3.14 nicht mehr Default. Copy-on-Write zerfällt in Python zudem schnell, weil der Referenzzähler Speicherseiten beschreibt. | +| `forkserver` mit vorgeladenen Modulen | Theoretisch die Vorteile von `fork` ohne die Thread-Probleme. Mehr Komplexität, plattformabhängig (nicht unter macOS-Default), Nutzen wegen Copy-on-Write-Zerfall unsicher. Erst angehen, wenn viele Worker pro Pod nötig sind. | +| Worker ohne Pool (Berechnung im Server-Thread) | Spart die Worker komplett, aber dann gibt es keinen harten Timeout und keine Isolation mehr. `9^9^9^9` würde den Server blockieren. Nur für Entwicklung (`MCP_CALC_EXECUTOR=inline`). | + +## Dimensionierung + +**Worker-Anzahl.** Ein Worker rechnet genau eine Anfrage gleichzeitig. `MCP_CALC_WORKERS` legt +also die Parallelität fest. Weitere Anfragen warten in der Queue. Faustregel: Worker ≈ CPU-Limit +des Pods. Mehr Worker als CPUs bringt bei CPU-lastigen Berechnungen nichts. + +**Speicher.** Im Leerlauf gilt ~90 MB + Worker × ~140 MB. Unter Linux darf eine laufende +Berechnung pro Worker zusätzlich höchstens `MCP_CALC_MEMORY_LIMIT_MB` belegen (Default 512 MB). +Darüber wird sie mit `MemoryError` abgebrochen, der Worker bleibt stabil. + +Technisch ist das ein `RLIMIT_AS` auf den **virtuellen** Adressraum. Er wird erst *nach* dem Laden +der Bibliotheken gesetzt, als „aktueller Stand + Limit“. Hintergrund: NumPy/SciPy reservieren deutlich mehr +virtuellen Speicher, als sie belegen (RSS). Ein absolutes Limit vor den Imports könnte schon den +Import scheitern lassen. Weil virtueller Zuwachs ≥ echter Zuwachs ist, ist die Formel konservativ. +Zusätzlich laufen BLAS/OpenMP pro Worker einthreadig (`OPENBLAS_NUM_THREADS=1` usw., sofern nicht +anders gesetzt). Das verhindert Thread-Puffer pro CPU-Kern und die Überbuchung des CPU-Limits. + +Empfehlung für das Pod-Limit: + +``` +limit ≥ 90 MB + WORKERS × (140 MB + MEMORY_LIMIT_MB) +``` + +| Profil | `WORKERS` | `MEMORY_LIMIT_MB` | Pod-Limit | Pod-Request | +|---|---|---|---|---| +| klein (Einzelteam) | 1 | 512 | ~768 Mi | ~256 Mi | +| **Standard (Defaults)** | 2 | 512 | ~1.4 Gi | ~400 Mi | +| großzügig | 2 | 1024 | ~2.4 Gi | ~400 Mi | +| parallel | 4 | 512 | ~2.7 Gi | ~700 Mi | + +Request ≈ Leerlaufbedarf, Limit ≈ Worst Case (alle Worker gleichzeitig am Speicherlimit). Wird das +Pod-Limit kleiner gewählt, kann ein einzelner Ausreißer den ganzen Pod per OOM-Kill beenden, statt +nur seine eigene Berechnung. + +> **Ungetestet unter Linux:** Das Speicherlimit und die Thread-Einstellungen greifen nur unter Linux +> und konnten hier (macOS) nicht im Betrieb geprüft werden, nur die Berechnung des Limits per Unit-Test. +> Bitte im Pod einmal prüfen: `python scripts/measure_memory.py server` sowie eine bewusst +> speicherhungrige Anfrage (z. B. `evaluate` mit `factorial(10^7)`, die mit einer Memory- oder +> Timeout-Meldung enden sollte, ohne dass der Pod neu startet). + +**Alterung.** SymPy baut interne Caches auf. Deshalb wird jeder Worker nach +`MCP_CALC_MAX_TASKS_PER_WORKER` Aufgaben (Default 200) durch einen frischen ersetzt. Der Neustart +kostet ~0,5 s in einem Worker, während die anderen weiterrechnen. diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..1ebb6aa --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,236 @@ +# Tool-Referenz + +13 Tools, nach Fachgebiet gruppiert. Die Tools mit vielen Operationen (`calculus`, +`matrix`, `number_theory` …) nutzen ein `operation`-Enum. So bleibt die Tool-Liste +für das LLM überschaubar, und die Schemas bleiben trotzdem präzise. + +Alle Tools sind `readOnly`, `idempotent` und `openWorld=false` annotiert. Clients können +sie also ohne Rückfrage freigeben (z. B. Cline `autoApprove`). + +## Gemeinsame Konventionen + +**Ausdrucks-Syntax** (für alle String-Ausdrücke): + +| Schreibweise | Bedeutung | +|---|---| +| `x^2` oder `x**2` | Potenz | +| `2x`, `3(x+1)` | implizite Multiplikation | +| `5!` | Fakultät | +| `a % b`, `Mod(a, b)` | Modulo | +| `pi`, `e`, `I`, `oo` | π, Euler'sche Zahl, imaginäre Einheit, ∞ | +| `sin(30*deg)` | Grad (deg = π/180) | +| `ln(x)`, `log(x)`, `log(x, 2)`, `log10(x)` | Logarithmen | +| `sqrt`, `cbrt`, `root(x, n)`, `abs`, `floor`, `ceiling` | Wurzeln, Betrag, Runden | +| `sin cos tan …`, `asin`/`arcsin` …, `sinh …` | Trigonometrie | +| `factorial`, `binomial`, `gamma`, `erf`, `min`, `max`, `gcd`, `lcm` | Sonderfunktionen | +| `x^2 = 4` | Gleichung (genau ein `=`) | +| `x^2 < 4`, `<=`, `>`, `>=`, `!=` | Ungleichungen | + +- Dezimalpunkt, kein Komma. Dezimalzahlen werden **exakt** als Brüche behandelt (`0.1 + 0.2 = 3/10`). +- Unicode wird normalisiert: `π × ÷ − ² ³ √ ∞ ≤ ≥ ≠`. +- Mehrbuchstabige Namen sind **ein** Symbol (`rate*time`, nicht `r*a*t*e`). + +**Ergebnisse:** +- `result` bzw. `exact`: symbolisches Ergebnis in derselben Syntax, direkt als Eingabe für das nächste Tool verwendbar +- `latex`: für die Darstellung +- `numeric`: Dezimalwert als String, damit keine Präzision verloren geht +- Sehr lange Ergebnisse werden gekürzt (`MCP_CALC_MAX_OUTPUT_LENGTH`), große Ganzzahlen enthalten zusätzlich `digits`. + +**Fehler** kommen als Tool-Fehler (`isError: true`) mit einer verständlichen Meldung zurück, +z. B. `matrix is singular (determinant 0) and has no inverse; try 'pseudo_inverse'`. +Das LLM kann darauf reagieren und den Aufruf korrigieren. + +--- + +## evaluate + +Wert exakt und dezimal berechnen, mit beliebiger Präzision. + +| Parameter | Typ | Default | | +|---|---|---|---| +| `expression` | string | – | Ausdruck | +| `precision` | int | 15 | signifikante Stellen (1–1000) | +| `variables` | object | – | Werte einsetzen, z. B. `{"x": 2, "r": "1/3"}` | + +```json +{"expression": "1000 * (1 + 0.05)^10", "precision": 12} +→ {"exact": "16679880978201/10240000000", "numeric": "1628.89462678", ...} +``` + +## transform + +Symbolisch umformen. + +| Parameter | Typ | Default | | +|---|---|---|---| +| `expression` | string | – | | +| `operation` | enum | `simplify` | `simplify`, `expand`, `factor`, `collect`, `cancel`, `apart`, `together`, `trigsimp`, `expand_trig`, `powsimp`, `expand_log`, `logcombine`, `radsimp`, `rationalize_denominator`, `nsimplify`, `substitute` | +| `variable` | string | – | Hauptvariable für `collect`, `apart`, `factor` | +| `substitutions` | object | – | für `substitute`: `{"x": "2t"}` | +| `assume` | enum | `none` | `none`, `real`, `positive`, `integer` | + +`equivalent_to_input: true` bestätigt, dass die Umformung mathematisch identisch ist. +`nsimplify` findet geschlossene Formen zu Dezimalzahlen (`1.41421356…` → `sqrt(2)`). + +## solve + +Gleichungen, Gleichungssysteme, Ungleichungen. + +| Parameter | Typ | Default | | +|---|---|---|---| +| `equations` | string \| string[] | – | eine Gleichung oder ein System; bloßer Ausdruck = `… = 0` | +| `variables` | string[] | alle freien | wonach aufgelöst wird | +| `domain` | enum | `complex` | `complex`, `real`, `positive`, `integer` | +| `numeric_only` | bool | false | numerisch lösen (Newton / Polynomwurzeln) | +| `initial_guess` | number[] | 1.0 je Variable | Startwerte für `numeric_only` | +| `precision` | int | 15 | | + +- Endliche Lösungen: `solutions: [{"x": "2"}, {"x": "3"}]` plus `numeric` +- Periodische Lösungen (z. B. `sin(x) = 1/2`): `solution_set` als Menge plus `principal_solutions` +- Unterbestimmte Systeme: parametrisierte Lösung plus Hinweis in `note` +- Ungleichungen: `solution_set: "Interval.open(-2, 2)"` +- Transzendente Gleichungen ohne geschlossene Form: `numeric_only: true` + +## calculus + +| `operation` | benötigt | Ergebnis | +|---|---|---| +| `derivative` | `variable`, `order` | Ableitung; mehrere Variablen `"x, y"` = gemischte partielle Ableitung | +| `integral` | optional `lower`+`upper` | unbestimmt (+C-Hinweis) oder bestimmt; fällt numerisch zurück, wenn keine geschlossene Form existiert | +| `limit` | `point`, `direction` (`+-`, `+`, `-`) | Grenzwert | +| `series` | `point` (Default 0), `terms` | Taylorreihe mit O-Term, zusätzlich `polynomial` | +| `sum` / `product` | `lower`, `upper` (auch `oo`) | geschlossene Form oder numerisch; `converges` bei unendlichen Summen | +| `gradient`, `hessian` | `variable: "x, y"` | Vektor / Matrix | +| `jacobian` | `expression: "f1, f2"`, `variable: "x, y"` | Matrix | + +Weitere Parameter: `assume`, `precision`. + +## analyze_function + +Komplette Kurvendiskussion einer reellen Funktion f(x) in einem Aufruf: +Definitionsbereich, f′/f″/f‴, Nullstellen, y-Achsenabschnitt, Extrema mit Art, Wendepunkte, +Symmetrie, Grenzverhalten ±∞, senkrechte Asymptoten, Monotonie- und Krümmungsintervalle. +Teilschritte, die sich nicht bestimmen lassen, liefern `"not determinable"`, statt den ganzen Aufruf scheitern zu lassen. + +| Parameter | Default | +|---|---| +| `expression` | – | +| `variable` | `x` | +| `precision` | 10 | + +## solve_ode + +Gewöhnliche Differentialgleichungen, symbolisch gelöst. + +| Parameter | Default | | +|---|---|---| +| `equation` | – | Strich-Notation: `"y'' + 2y' + y = sin(x)"` | +| `function` | `y` | Name der gesuchten Funktion | +| `variable` | `x` | unabhängige Variable | +| `initial_conditions` | – | `{"y(0)": 1, "y'(0)": 0}` | + +Ohne Anfangsbedingungen kommt die allgemeine Lösung mit `C1, C2, …`. Zusätzlich wird das Lösungsverfahren (`method`) zurückgegeben. + +## matrix + +| Parameter | Default | | +|---|---|---| +| `operation` | – | siehe unten | +| `matrix` | – | Liste von Zeilen `[[1, 2], [3, 4]]`; Einträge dürfen Ausdrücke sein (`"1/3"`, `"a"`); flache Liste = Spaltenvektor | +| `other` | – | zweiter Operand bzw. rechte Seite `b` bei `solve` | +| `exponent` | – | für `power` | +| `numeric_mode` | false | Gleitkomma (NumPy) statt exakt | + +Operationen (exakt): `determinant`, `inverse`, `transpose`, `rank`, `trace`, `rref`, `eigenvalues`, +`eigenvectors`, `characteristic_polynomial`, `diagonalize`, `nullspace`, `columnspace`, `multiply`, `add`, +`subtract`, `power`, `solve`, `dot`, `cross`, `norm`, `pseudo_inverse`. +Nur numerisch: `svd`, `condition_number`. Bei `numeric_mode` löst `solve` nicht-quadratische Systeme per Least Squares. + +## statistics + +Deskriptive Statistik: count, sum, mean, median, mode, min/max, range, variance, std_dev, Quartile, IQR, +Standardfehler, Variationskoeffizient, Schiefe, Exzess, geometrisches/harmonisches Mittel. + +| Parameter | Default | | +|---|---|---| +| `data` | – | Zahlen | +| `sample` | true | Stichprobe (n−1) oder Grundgesamtheit (n) | +| `percentiles` | – | z. B. `[5, 95]` | +| `y` | – | gepaarte zweite Variable: Kovarianz, Pearson, Spearman | + +## regression + +| Parameter | Default | | +|---|---|---| +| `x`, `y` | – | Datenpunkte | +| `model` | `linear` | `linear`, `polynomial`, `exponential`, `logarithmic`, `power` | +| `degree` | 2 | für `polynomial` | +| `predict` | – | x-Werte für Vorhersagen | + +Liefert Koeffizienten, Gleichung, R², RMSE; beim linearen Modell zusätzlich r, p-Wert und Standardfehler. + +## probability + +| Parameter | | +|---|---| +| `distribution` | `normal(mean, std)`, `t(df)`, `chi2(df)`, `f(dfn, dfd)`, `exponential(rate)`, `uniform(low, high)`, `lognormal(mu, sigma)`, `gamma(shape, scale)`, `beta(a, b)`, `binomial(n, p)`, `poisson(lambda)`, `geometric(p)`, `hypergeometric(population, successes, draws)`, `negative_binomial(successes, p)` | +| `operation` | `pdf` (Dichte bzw. P(X=x)), `cdf` P(X≤x), `sf` P(X>x), `between` P(lower 7) = P(X >= 8)"`. Das verhindert die typischen Off-by-one-Fehler von LLMs. + +## hypothesis_test + +| `test` | Eingaben | +|---|---| +| `one_sample_t` | `sample`, `mu` | +| `two_sample_t` | `sample`, `sample2`, `equal_variance` (Default Welch) | +| `paired_t` | `sample`, `sample2` | +| `one_way_anova` | `groups` | +| `mann_whitney` | `sample`, `sample2` | +| `wilcoxon` | `sample` + `sample2` oder `mu` | +| `chi2_goodness_of_fit` | `sample` (beobachtete Häufigkeiten), `expected` (Häufigkeiten oder Wahrscheinlichkeiten) | +| `chi2_independence` | `table` (Kontingenztafel), zusätzlich Cramér's V | +| `shapiro_wilk` | `sample` | +| `binomial` | `sample: [Erfolge, Versuche]`, `p` | +| `pearson_correlation` | `sample`, `sample2` | + +Gemeinsam: `alternative` (`two-sided`, `less`, `greater`) und `alpha`. Ergebnis: Teststatistik, +p-Wert, gegebenenfalls Freiheitsgrade und Konfidenzintervall, `reject_null` und eine Schlussfolgerung im Klartext. + +## number_theory + +| `operation` | `values` | +|---|---| +| `gcd`, `lcm` | `[a, b, …]` | +| `factorize`, `is_prime`, `next_prime`, `previous_prime`, `divisors`, `totient`, `factorial`, `fibonacci` | `[n]` | +| `nth_prime` | `[k]` | +| `primes_in_range` | `[low, high]` | +| `mod` | `[a, m]` | +| `mod_pow` | `[base, exp, mod]` | +| `mod_inverse` | `[a, m]` | +| `crt` | Reste in `values`, Moduli in `moduli` | +| `binomial`, `permutations` | `[n, k]` | +| `continued_fraction` | `[x]` (z. B. `"415/93"`, `"sqrt(2)"`) | +| `base_convert` | `["ff"]` plus `from_base`, `to_base` (2–36) | + +Ganzzahlen über 2^53 bitte als String übergeben. JSON-Zahlen verlieren sonst in manchen +Clients Stellen. Große Ergebnisse kommen ebenfalls als String zurück. + +## convert_units + +| Parameter | Default | | +|---|---|---| +| `quantity` | – | `"100 km/h"`, `"25 degC"`, `"5 km/h * 2 h"`, `"1.5 kg * 9.81 m/s^2"` | +| `to_unit` | – | Zieleinheit; ohne Angabe SI-Basiseinheiten plus `compact` | +| `precision` | 12 | | + +Die Dimensionen werden geprüft (`kg` → `m` ergibt einen Fehler). Unterstützt werden SI- und imperiale Einheiten, Präfixe, +Temperaturen, Datengrößen (`B`, `KiB`, `MB`), Energie, Druck usw. (Pint-Einheitenregister). + +## Ressource `calculator://syntax` + +Kurzreferenz der Syntax und Verteilungen als Text. Dieselbe Information steckt auch in den +Server-`instructions`, die jeder Client beim Verbindungsaufbau erhält. diff --git a/examples/client_example.py b/examples/client_example.py new file mode 100644 index 0000000..608444c --- /dev/null +++ b/examples/client_example.py @@ -0,0 +1,59 @@ +"""Minimal MCP client for your own tools and agents. + +Usage: + python examples/client_example.py # starts the server via stdio + python examples/client_example.py http://localhost:8080/mcp [TOKEN] + +Wiring into your own LLM loop: + 1. ``list_tools()`` -> pass name / description / input_schema of each tool to the + model as tool definitions (every major LLM API uses this JSON-schema shape). + 2. When the model requests a tool call -> ``call_tool(name, arguments)``. + 3. Return ``result.content[0].text`` (or ``structured_content``) to the model; + mark it as an error if ``result.is_error`` is true. +""" + +from __future__ import annotations + +import asyncio +import sys + +import httpx2 +from mcp import Client, StdioServerParameters +from mcp.client.streamable_http import streamable_http_client + + +async def demo(client: Client) -> None: + print("Server instructions:\n", client.instructions, "\n", sep="") + + tools = (await client.list_tools()).tools + print("Tools:", ", ".join(t.name for t in tools), "\n") + + calls = [ + ("evaluate", {"expression": "sqrt(2)", "precision": 40}), + ("solve", {"equations": ["x + y = 10", "x - y = 2"]}), + ("calculus", {"operation": "integral", "expression": "x^2 * e^x"}), + ("probability", {"distribution": "binomial", "operation": "cdf", "parameters": {"n": 20, "p": 0.3}, "x": 5}), + ("convert_units", {"quantity": "60 mph", "to_unit": "km/h"}), + ("matrix", {"operation": "inverse", "matrix": [[1, 2], [2, 4]]}), # error on purpose + ] + for name, arguments in calls: + result = await client.call_tool(name, arguments) + status = "ERROR" if result.is_error else "ok" + print(f"[{status}] {name}({arguments})\n{result.content[0].text}\n") + + +async def main() -> None: + if len(sys.argv) > 1: + url = sys.argv[1] + headers = {"Authorization": f"Bearer {sys.argv[2]}"} if len(sys.argv) > 2 else {} + async with httpx2.AsyncClient(headers=headers, timeout=60) as http: + async with Client(streamable_http_client(url, http_client=http)) as client: + await demo(client) + else: + params = StdioServerParameters(command=sys.executable, args=["-m", "mcp_calculator"]) + async with Client(params) as client: + await demo(client) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4522fa2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "mcp-calculator" +version = "1.2.0" +description = "MCP server that gives LLMs exact, deterministic math: symbolic algebra, calculus, linear algebra, statistics, units." +readme = "README.md" +requires-python = ">=3.12" +license = { text = "Proprietary" } +dependencies = [ + "mcp>=2.2,<3", + "sympy>=1.13", + "mpmath>=1.3", + "numpy>=2.0", + "scipy>=1.13", + "pint>=0.24", + "pebble>=5.1", + "prometheus-client>=0.20", + "starlette>=0.40", + "uvicorn>=0.30", +] + +[project.optional-dependencies] +dev = [ + "ruff>=0.6", +] + +[project.scripts] +mcp-calculator = "mcp_calculator.__main__:main" +mcp-calculator-admin = "mcp_calculator.admin_cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/mcp_calculator"] + +[tool.ruff] +line-length = 160 +target-version = "py312" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = ["E501", "B008", "UP007"] diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..903d6d4 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,596 @@ +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ + --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 + # via + # httpx2 + # mcp + # sse-starlette + # starlette +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + # via uvicorn +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via pyjwt +flexcache==0.3 \ + --hash=sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656 \ + --hash=sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32 + # via pint +flexparser==0.4 \ + --hash=sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2 \ + --hash=sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846 + # via pint +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # httpcore2 + # uvicorn +httpcore2==2.12.0 ; sys_platform != 'emscripten' \ + --hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb \ + --hash=sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648 + # via httpx2 +httpx2==2.12.0 \ + --hash=sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf \ + --hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36 + # via mcp +httpx2-jsfetch==1.0 ; sys_platform == 'emscripten' \ + --hash=sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60 \ + --hash=sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32 + # via httpx2 +idna==3.19 \ + --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 + # via + # anyio + # httpx2 +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via mcp +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +mcp==2.2.0 \ + --hash=sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd \ + --hash=sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81 + # via mcp-calculator +mcp-types==2.2.0 \ + --hash=sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad \ + --hash=sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13 + # via mcp +mpmath==1.3.0 \ + --hash=sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f \ + --hash=sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c + # via + # mcp-calculator + # sympy +numpy==2.5.3 \ + --hash=sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c \ + --hash=sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be \ + --hash=sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7 \ + --hash=sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def \ + --hash=sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2 \ + --hash=sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0 \ + --hash=sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6 \ + --hash=sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551 \ + --hash=sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f \ + --hash=sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159 \ + --hash=sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6 \ + --hash=sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0 \ + --hash=sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5 \ + --hash=sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10 \ + --hash=sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a \ + --hash=sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a \ + --hash=sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c \ + --hash=sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff \ + --hash=sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33 \ + --hash=sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0 \ + --hash=sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85 \ + --hash=sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034 \ + --hash=sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0 \ + --hash=sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a \ + --hash=sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4 \ + --hash=sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c \ + --hash=sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec \ + --hash=sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58 \ + --hash=sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997 \ + --hash=sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90 \ + --hash=sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204 \ + --hash=sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e \ + --hash=sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211 \ + --hash=sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435 \ + --hash=sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104 \ + --hash=sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321 \ + --hash=sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058 \ + --hash=sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0 \ + --hash=sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297 \ + --hash=sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07 \ + --hash=sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694 \ + --hash=sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b \ + --hash=sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034 \ + --hash=sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3 \ + --hash=sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231 \ + --hash=sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc \ + --hash=sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d \ + --hash=sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4 \ + --hash=sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653 \ + --hash=sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e \ + --hash=sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b \ + --hash=sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9 \ + --hash=sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034 \ + --hash=sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958 \ + --hash=sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832 \ + --hash=sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563 \ + --hash=sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034 \ + --hash=sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841 \ + --hash=sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae \ + --hash=sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469 \ + --hash=sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c \ + --hash=sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891 \ + --hash=sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5 \ + --hash=sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09 \ + --hash=sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e \ + --hash=sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab + # via + # mcp-calculator + # scipy +opentelemetry-api==1.44.0 \ + --hash=sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a \ + --hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef + # via mcp +pebble==5.2.2 \ + --hash=sha256:1058e3ed41c6bb5cc16fcf6a249502cd4637a597211b997d9a0a499f46bc91a4 \ + --hash=sha256:eb49a0f6862f7e75df4a40107f1c8c267cf8adfbd6d29a0dac29d17ac188c878 + # via mcp-calculator +pint==0.26.1 \ + --hash=sha256:1bbde36eae57a5a289cd05081c6405618a5899814940752064ce351cd0204f71 \ + --hash=sha256:e982b129415c09c63308f314ae44697d83e5c96f253bcc0d5b833fc3329eb6d4 + # via mcp-calculator +platformdirs==4.11.8 \ + --hash=sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081 \ + --hash=sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc + # via pint +prometheus-client==0.26.0 \ + --hash=sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b \ + --hash=sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6 + # via mcp-calculator +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.5 \ + --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \ + --hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08 + # via + # mcp + # mcp-types +pydantic-core==2.46.5 \ + --hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \ + --hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \ + --hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \ + --hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \ + --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \ + --hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \ + --hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \ + --hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \ + --hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \ + --hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \ + --hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \ + --hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \ + --hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \ + --hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \ + --hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \ + --hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \ + --hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \ + --hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \ + --hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \ + --hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \ + --hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \ + --hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \ + --hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \ + --hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \ + --hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \ + --hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \ + --hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \ + --hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \ + --hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \ + --hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \ + --hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \ + --hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \ + --hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \ + --hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \ + --hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \ + --hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \ + --hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \ + --hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \ + --hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \ + --hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \ + --hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \ + --hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \ + --hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \ + --hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \ + --hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \ + --hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \ + --hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \ + --hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \ + --hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \ + --hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \ + --hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \ + --hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \ + --hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \ + --hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \ + --hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \ + --hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \ + --hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \ + --hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \ + --hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \ + --hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \ + --hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \ + --hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \ + --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \ + --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \ + --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9 + # via pydantic +pyjwt==2.14.0 \ + --hash=sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86 \ + --hash=sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc + # via mcp +python-multipart==0.0.32 \ + --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ + --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 + # via mcp +pywin32==312 ; sys_platform == 'win32' \ + --hash=sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831 \ + --hash=sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed \ + --hash=sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950 \ + --hash=sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c \ + --hash=sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa \ + --hash=sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e \ + --hash=sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b \ + --hash=sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9 \ + --hash=sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc \ + --hash=sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5 \ + --hash=sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9 \ + --hash=sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b + # via mcp +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # jsonschema + # referencing +scipy==1.18.1 \ + --hash=sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc \ + --hash=sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5 \ + --hash=sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123 \ + --hash=sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7 \ + --hash=sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd \ + --hash=sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239 \ + --hash=sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0 \ + --hash=sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb \ + --hash=sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35 \ + --hash=sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d \ + --hash=sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89 \ + --hash=sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5 \ + --hash=sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe \ + --hash=sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3 \ + --hash=sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89 \ + --hash=sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1 \ + --hash=sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305 \ + --hash=sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307 \ + --hash=sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28 \ + --hash=sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230 \ + --hash=sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2 \ + --hash=sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174 \ + --hash=sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba \ + --hash=sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66 \ + --hash=sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12 \ + --hash=sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d \ + --hash=sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0 \ + --hash=sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7 \ + --hash=sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82 \ + --hash=sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487 \ + --hash=sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168 \ + --hash=sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0 \ + --hash=sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f \ + --hash=sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729 \ + --hash=sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9 \ + --hash=sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3 \ + --hash=sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad \ + --hash=sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443 \ + --hash=sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d \ + --hash=sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314 \ + --hash=sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899 \ + --hash=sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23 \ + --hash=sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09 \ + --hash=sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf \ + --hash=sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa \ + --hash=sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87 \ + --hash=sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1 \ + --hash=sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315 \ + --hash=sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12 \ + --hash=sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4 \ + --hash=sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f \ + --hash=sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07 \ + --hash=sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298 \ + --hash=sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93 \ + --hash=sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265 \ + --hash=sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6 \ + --hash=sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331 \ + --hash=sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a \ + --hash=sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7 \ + --hash=sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218 \ + --hash=sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9 + # via mcp-calculator +sse-starlette==3.4.11 \ + --hash=sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade \ + --hash=sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453 + # via mcp +starlette==1.6.0 \ + --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \ + --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b + # via + # mcp + # mcp-calculator + # sse-starlette +sympy==1.14.0 \ + --hash=sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517 \ + --hash=sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 + # via mcp-calculator +truststore==0.10.4 ; sys_platform != 'emscripten' \ + --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 + # via + # httpcore2 + # httpx2 +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # flexcache + # flexparser + # httpx2 + # mcp + # mcp-types + # opentelemetry-api + # pint + # pydantic + # pydantic-core + # referencing + # starlette + # typing-inspection +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + # via + # mcp + # pydantic +uvicorn==0.52.4 \ + --hash=sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86 \ + --hash=sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1 + # via + # mcp + # mcp-calculator diff --git a/scripts/measure_memory.py b/scripts/measure_memory.py new file mode 100644 index 0000000..6ab9985 --- /dev/null +++ b/scripts/measure_memory.py @@ -0,0 +1,141 @@ +"""Measure the memory footprint of mcp-calculator (see docs/memory.md). + +Usage (inside the project venv): + python scripts/measure_memory.py imports # RSS cost of each library, cumulative + python scripts/measure_memory.py server # start an HTTP server, warm it up, measure all processes + python scripts/measure_memory.py server --workers 4 + +Only the standard library is used, so it also runs in a container. RSS is read +via ``ps`` (macOS and Linux/procps). +""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import subprocess +import sys +import time +import urllib.request + +# Cumulative import steps in the order the server loads them. +IMPORT_STEPS = [ + ("Python interpreter", []), + ("+ NumPy", ["numpy"]), + ("+ SymPy", ["sympy"]), + ("+ SciPy (scipy.stats)", ["scipy.stats"]), + ("+ Pint", ["pint"]), + ("+ MCP SDK, Pydantic", ["mcp.server.mcpserver"]), + ("+ uvicorn, Starlette", ["uvicorn", "starlette"]), + ("+ Pebble", ["pebble"]), +] + +WARMUP_CALLS = [ + ("evaluate", {"expression": "sqrt(2)", "precision": 50}), + ("solve", {"equations": ["x^2 + y^2 = 25", "x - y = 1"]}), + ("calculus", {"operation": "integral", "expression": "x*e^x"}), + ("analyze_function", {"expression": "x^3 - 3x"}), + ("matrix", {"operation": "eigenvalues", "matrix": [[2, 1], [1, 2]]}), + ("statistics", {"data": [1, 2, 3, 4, 5]}), + ("probability", {"distribution": "normal", "operation": "cdf", "x": 1}), + ("convert_units", {"quantity": "100 km/h", "to_unit": "m/s"}), +] + + +def rss_mb(pid: int) -> float: + return int(subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)])) / 1024 + + +def measure_imports() -> None: + print(f"{'step':<28}{'RSS total':>10}{'delta':>8}") + previous = None + for label, _ in IMPORT_STEPS: + modules = [m for _, mods in IMPORT_STEPS[: [s[0] for s in IMPORT_STEPS].index(label) + 1] for m in mods] + code = "import importlib, os, subprocess\n" + code += "".join(f"importlib.import_module({m!r})\n" for m in modules) + code += "print(int(subprocess.check_output(['ps', '-o', 'rss=', '-p', str(os.getpid())])))" + total = int(subprocess.check_output([sys.executable, "-c", code])) / 1024 + delta = "" if previous is None else f"+{total - previous:.0f}" + print(f"{label:<28}{total:>8.0f} MB{delta:>8}") + previous = total + + +def _children(pid: int) -> list[tuple[int, str]]: + out = subprocess.check_output(["ps", "-A", "-o", "pid=,ppid=,command="], text=True) + result = [] + for line in out.splitlines(): + parts = line.split(None, 2) + if len(parts) == 3 and int(parts[1]) == pid: + result.append((int(parts[0]), parts[2])) + return result + + +def _call(url: str, tool: str, arguments: dict) -> None: + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": arguments}}).encode() + request = urllib.request.Request( + url, + data=body, + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": "2025-06-18", + }, + ) + with urllib.request.urlopen(request, timeout=60) as response: + response.read() + + +def measure_server(workers: int) -> None: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + env = {**os.environ, "MCP_CALC_TRANSPORT": "http", "MCP_CALC_PORT": str(port), "MCP_CALC_WORKERS": str(workers), "MCP_CALC_LOG_LEVEL": "WARNING"} + proc = subprocess.Popen([sys.executable, "-m", "mcp_calculator"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + base = f"http://127.0.0.1:{port}" + try: + started = time.perf_counter() + while True: + try: + urllib.request.urlopen(f"{base}/readyz", timeout=1).read() + break + except OSError: + if time.perf_counter() - started > 60: + raise SystemExit("server did not become ready") from None + time.sleep(0.1) + ready_after = time.perf_counter() - started + + first = time.perf_counter() + _call(f"{base}/mcp", "evaluate", {"expression": "1+1"}) + first_call = time.perf_counter() - first + for _ in range(workers): # every worker should have computed something + for tool, arguments in WARMUP_CALLS: + _call(f"{base}/mcp", tool, arguments) + time.sleep(1) + + rows = [("server", rss_mb(proc.pid))] + for pid, command in _children(proc.pid): + rows.append(("resource tracker" if "resource_tracker" in command else "worker", rss_mb(pid))) + print(f"ready after {ready_after:.2f}s, first call {first_call:.2f}s, {workers} worker(s)\n") + for name, mb in rows: + print(f"{name:<18}{mb:>6.0f} MB") + print(f"{'total':<18}{sum(mb for _, mb in rows):>6.0f} MB") + finally: + proc.terminate() + proc.wait(timeout=30) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("mode", choices=["imports", "server"]) + parser.add_argument("--workers", type=int, default=2) + args = parser.parse_args() + if args.mode == "imports": + measure_imports() + else: + measure_server(args.workers) + + +if __name__ == "__main__": + main() diff --git a/src/mcp_calculator/__init__.py b/src/mcp_calculator/__init__.py new file mode 100644 index 0000000..0e6d4b8 --- /dev/null +++ b/src/mcp_calculator/__init__.py @@ -0,0 +1,3 @@ +"""MCP server providing exact, deterministic math tools for LLMs.""" + +__version__ = "1.2.0" diff --git a/src/mcp_calculator/__main__.py b/src/mcp_calculator/__main__.py new file mode 100644 index 0000000..35fe204 --- /dev/null +++ b/src/mcp_calculator/__main__.py @@ -0,0 +1,51 @@ +"""Entry point: ``mcp-calculator`` or ``python -m mcp_calculator``. + +Keep the module-level imports of this file light. Worker processes are started +with ``spawn``, which re-imports the parent's main module in every worker; heavy +imports here (MCP SDK, uvicorn, Starlette) would be loaded into each worker for +nothing. They are therefore imported inside the functions (see docs/memory.md). +""" + +from __future__ import annotations + +import logging +import sys + +from mcp_calculator.config import Settings, load_settings +from mcp_calculator.logging_setup import setup_logging + +logger = logging.getLogger("mcp_calculator") + + +async def _serve(settings: Settings) -> None: + from mcp_calculator.executor import Executor + from mcp_calculator.server import create_server + + executor = Executor(settings) + executor.start() + mcp = create_server(executor) + try: + if settings.transport == "http": + from mcp_calculator.http_app import serve_http + + await serve_http(mcp, settings, executor) + else: + logger.info("serving MCP over stdio") + await mcp.run_stdio_async() + finally: + executor.stop() + + +def main(argv: list[str] | None = None) -> None: + import anyio + + settings = load_settings(sys.argv[1:] if argv is None else argv) + setup_logging(settings.log_level, settings.log_format) + try: + anyio.run(_serve, settings) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/src/mcp_calculator/admin.py b/src/mcp_calculator/admin.py new file mode 100644 index 0000000..ebb978d --- /dev/null +++ b/src/mcp_calculator/admin.py @@ -0,0 +1,214 @@ +"""Client administration: one service layer used by the admin API and the CLI. + +Every change is written to the audit log (one JSON object per line) with actor, +action, client and the changed fields; token values and hashes are never logged. +""" + +from __future__ import annotations + +import hmac +import json +import logging +import os +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from mcp_calculator.clients import Client, ClientConfigError, ClientFile, hash_token, new_token, now_iso, validate_name + +logger = logging.getLogger(__name__) +audit_logger = logging.getLogger("mcp_calculator.audit") + + +class NotFound(LookupError): + pass + + +class Conflict(ValueError): + pass + + +class AuditLog: + def __init__(self, path: str | os.PathLike[str] | None) -> None: + self.path = Path(path) if path else None + + def record(self, actor: str, action: str, client: str, details: dict[str, Any] | None = None) -> None: + entry = {"time": datetime.now(UTC).isoformat(timespec="seconds"), "actor": actor, "action": action, "client": client} + if details: + entry["details"] = details + line = json.dumps(entry, ensure_ascii=False, default=str) + audit_logger.info(line) + if self.path is not None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with open(self.path, "a", encoding="utf-8") as f: + f.write(line + "\n") + + +class ClientAdmin: + def __init__(self, file: ClientFile, audit: AuditLog, on_change: Callable[[], None] | None = None) -> None: + self.file = file + self.audit = audit + self.on_change = on_change or (lambda: None) + + def list(self) -> list[dict[str, Any]]: + return [c.public_view() for _, c in sorted(self.file.read().items())] + + def get(self, name: str) -> dict[str, Any]: + clients = self.file.read() + if name not in clients: + raise NotFound(f"client {name!r} not found") + return clients[name].public_view() + + def create( + self, + name: str, + *, + actor: str, + auth: str = "token", + max_concurrent: int | None = None, + expires: str | None = None, + allowed_networks: list[str] | None = None, + note: str = "", + ) -> tuple[dict[str, Any], str | None]: + validate_name(name) + token = new_token() if auth == "token" else None + data: dict[str, Any] = { + "auth": auth, + "max_concurrent": max_concurrent, + "expires": expires, + "allowed_networks": allowed_networks or [], + "note": note, + "created": now_iso(), + } + if token: + data["token_sha256"] = hash_token(token) + data["token_hint"] = token[:9] + client = Client.from_dict(name, data) + with self.file.update() as clients: + if name in clients: + raise Conflict(f"client {name!r} already exists") + clients[name] = client + self._changed(actor, "create", name, client.to_dict(include_hash=False)) + return client.public_view(), token + + def update(self, name: str, changes: dict[str, Any], *, actor: str) -> dict[str, Any]: + with self.file.update() as clients: + if name not in clients: + raise NotFound(f"client {name!r} not found") + clients[name] = clients[name].with_changes(changes) + client = clients[name] + self._changed(actor, "update", name, changes) + return client.public_view() + + def rotate(self, name: str, *, actor: str) -> tuple[dict[str, Any], str]: + token = new_token() + with self.file.update() as clients: + if name not in clients: + raise NotFound(f"client {name!r} not found") + if clients[name].auth != "token": + raise ClientConfigError(f"client {name!r} authenticates by network and has no token") + old = clients[name] + clients[name] = Client.from_dict(name, {**old.to_dict(), "token_sha256": hash_token(token), "token_hint": token[:9], "updated": now_iso()}) + client = clients[name] + self._changed(actor, "rotate", name) + return client.public_view(), token + + def delete(self, name: str, *, actor: str) -> None: + with self.file.update() as clients: + if clients.pop(name, None) is None: + raise NotFound(f"client {name!r} not found") + self._changed(actor, "delete", name) + + def _changed(self, actor: str, action: str, name: str, details: dict[str, Any] | None = None) -> None: + self.audit.record(actor, action, name, details) + self.on_change() + + +# --------------------------------------------------------------------------- admin API + + +def build_admin_app(admin: ClientAdmin, admin_tokens: list[str]) -> Starlette: + """Admin REST API. Runs on its own port; every route requires an admin token.""" + tokens = {name: hash_token(token) for name, _, token in (entry.partition(":") for entry in admin_tokens)} + + def actor_of(request: Request) -> str | None: + scheme, _, credentials = request.headers.get("authorization", "").partition(" ") + if scheme.lower() != "bearer" or not credentials: + return None + candidate = hash_token(credentials.strip()) + for name, expected in tokens.items(): + if hmac.compare_digest(candidate, expected): + return f"admin:{name}" + return None + + def handler(fn: Callable[..., Any]) -> Callable[[Request], Any]: + async def endpoint(request: Request) -> JSONResponse: + actor = actor_of(request) + if actor is None: + logger.warning("admin API: rejected request without valid admin token from %s", request.client.host if request.client else "?") + return JSONResponse({"error": "unauthorized"}, status_code=401, headers={"WWW-Authenticate": "Bearer"}) + try: + body = await request.json() if request.method in ("POST", "PATCH") and await request.body() else {} + if not isinstance(body, dict): + raise ClientConfigError("request body must be a JSON object") + status, payload = fn(request, body, actor) + return JSONResponse(payload, status_code=status) + except NotFound as exc: + return JSONResponse({"error": "not_found", "message": str(exc)}, status_code=404) + except Conflict as exc: + return JSONResponse({"error": "conflict", "message": str(exc)}, status_code=409) + except (ClientConfigError, json.JSONDecodeError, TypeError) as exc: + return JSONResponse({"error": "invalid", "message": str(exc)}, status_code=400) + + return endpoint + + def list_clients(request: Request, body: dict, actor: str) -> tuple[int, Any]: + return 200, {"clients": admin.list()} + + def create_client(request: Request, body: dict, actor: str) -> tuple[int, Any]: + allowed = {"name", "auth", "max_concurrent", "expires", "allowed_networks", "note"} + if set(body) - allowed: + raise ClientConfigError(f"unknown field(s) {sorted(set(body) - allowed)}") + if "name" not in body: + raise ClientConfigError("'name' is required") + client, token = admin.create(body.pop("name"), actor=actor, **body) + payload: dict[str, Any] = {"client": client} + if token: + payload["token"] = token + payload["note"] = "Store this token now; it cannot be shown again." + return 201, payload + + def get_client(request: Request, body: dict, actor: str) -> tuple[int, Any]: + return 200, admin.get(request.path_params["name"]) + + def update_client(request: Request, body: dict, actor: str) -> tuple[int, Any]: + return 200, admin.update(request.path_params["name"], body, actor=actor) + + def rotate_client(request: Request, body: dict, actor: str) -> tuple[int, Any]: + client, token = admin.rotate(request.path_params["name"], actor=actor) + return 200, {"client": client, "token": token, "note": "Store this token now; the previous token is invalid."} + + def delete_client(request: Request, body: dict, actor: str) -> tuple[int, Any]: + admin.delete(request.path_params["name"], actor=actor) + return 200, {"deleted": request.path_params["name"]} + + async def health(_: Request) -> JSONResponse: + return JSONResponse({"status": "ok"}) + + return Starlette( + routes=[ + Route("/healthz", health, methods=["GET"]), + Route("/admin/clients", handler(list_clients), methods=["GET"]), + Route("/admin/clients", handler(create_client), methods=["POST"]), + Route("/admin/clients/{name}", handler(get_client), methods=["GET"]), + Route("/admin/clients/{name}", handler(update_client), methods=["PATCH"]), + Route("/admin/clients/{name}", handler(delete_client), methods=["DELETE"]), + Route("/admin/clients/{name}/rotate", handler(rotate_client), methods=["POST"]), + ] + ) diff --git a/src/mcp_calculator/admin_cli.py b/src/mcp_calculator/admin_cli.py new file mode 100644 index 0000000..52c1e8c --- /dev/null +++ b/src/mcp_calculator/admin_cli.py @@ -0,0 +1,161 @@ +"""``mcp-calculator-admin``: manage API clients directly in the clients file. + +Works locally and inside the pod (``oc exec deploy/mcp-calculator -- mcp-calculator-admin list``). +Running servers pick up changes automatically (hot reload). +""" + +from __future__ import annotations + +import argparse +import getpass +import json +import os +import sys +from pathlib import Path +from typing import Any + +from mcp_calculator.admin import AuditLog, ClientAdmin, Conflict, NotFound +from mcp_calculator.clients import ClientConfigError, ClientFile + + +def _actor() -> str: + try: + return f"cli:{getpass.getuser()}" + except (KeyError, OSError): # arbitrary UID without passwd entry (OpenShift) + return f"cli:uid{os.getuid()}" + + +def _limit(value: str) -> int | None: + return None if value == "default" else int(value) + + +def _networks(value: str) -> list[str]: + return [] if value in ("", "none") else [v.strip() for v in value.split(",") if v.strip()] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mcp-calculator-admin", description="Manage API clients of mcp-calculator.") + parser.add_argument("--file", default=os.environ.get("MCP_CALC_CLIENTS_FILE"), help="clients file (default: $MCP_CALC_CLIENTS_FILE)") + parser.add_argument("--audit-file", default=os.environ.get("MCP_CALC_AUDIT_FILE"), help="audit log (default: audit.log next to the clients file)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + sub = parser.add_subparsers(dest="command", required=True) + + create = sub.add_parser("create", help="create a client and print its token once") + create.add_argument("name") + create.add_argument("--network-only", action="store_true", help="authenticate by source network only (no token); needs --networks") + create.add_argument("--networks", type=_networks, default=[], help="comma-separated IPs/CIDRs the client may connect from") + create.add_argument("--max-concurrent", type=_limit, default=None, help="concurrent computations (0 = unlimited, default = server default)") + create.add_argument("--expires", default=None, help="last valid day, e.g. 2027-03-31") + create.add_argument("--note", default="") + + sub.add_parser("list", help="list clients") + for name, text in (("show", "show one client"), ("enable", "enable a client"), ("disable", "disable (block) a client"), ("rotate", "issue a new token; the old one stops working")): + p = sub.add_parser(name, help=text) + p.add_argument("name") + + delete = sub.add_parser("delete", help="delete a client") + delete.add_argument("name") + delete.add_argument("--yes", action="store_true", help="do not ask for confirmation") + + # SUPPRESS: options that are not given do not appear in the namespace at all, + # so "not given" and "reset to default" (None) can be told apart. + update = sub.add_parser("set", help="change settings of a client", argument_default=argparse.SUPPRESS) + update.add_argument("name") + update.add_argument("--max-concurrent", type=_limit, help="number, 0 = unlimited, 'default' = server default") + update.add_argument("--expires", help="date or 'never'") + update.add_argument("--networks", type=_networks, help="comma-separated IPs/CIDRs or 'none'") + update.add_argument("--note") + return parser + + +def _print(data: Any, as_json: bool) -> None: + if as_json: + print(json.dumps(data, indent=2, ensure_ascii=False)) + return + if isinstance(data, dict) and "clients" in data: + rows = data["clients"] + if not rows: + print("no clients") + return + print(f"{'NAME':<24} {'STATUS':<9} {'AUTH':<8} {'LIMIT':<7} {'EXPIRES':<11} {'TOKEN':<11} NETWORKS") + for c in rows: + limit = "default" if c.get("max_concurrent") is None else str(c["max_concurrent"]) + print( + f"{c['name']:<24} {c['status']:<9} {c.get('auth', 'token'):<8} {limit:<7} {c.get('expires', '-'):<11} " + f"{(c.get('token_hint', '') + '…') if c.get('token_hint') else '-':<11} {','.join(c.get('allowed_networks', [])) or '-'}" + ) + return + for key, value in data.items(): + print(f"{key}: {value}") + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if not args.file: + print("error: no clients file (use --file or set MCP_CALC_CLIENTS_FILE)", file=sys.stderr) + return 2 + file = ClientFile(args.file) + audit = AuditLog(args.audit_file or Path(args.file).with_name("audit.log")) + admin = ClientAdmin(file, audit) + actor = _actor() + + try: + match args.command: + case "list": + _print({"clients": admin.list()}, args.json) + case "show": + _print(admin.get(args.name), args.json) + case "create": + if args.network_only and not args.networks: + raise ClientConfigError("--network-only needs --networks") + client, token = admin.create( + args.name, + actor=actor, + auth="network" if args.network_only else "token", + max_concurrent=args.max_concurrent, + expires=args.expires, + allowed_networks=args.networks, + note=args.note, + ) + if args.json: + _print({"client": client, "token": token}, True) + else: + _print(client, False) + if token: + print(f"\nTOKEN (shown only once, store it now):\n{token}") + case "enable" | "disable": + _print(admin.update(args.name, {"enabled": args.command == "enable"}, actor=actor), args.json) + case "set": + options = vars(args) + changes: dict[str, Any] = {} + if "max_concurrent" in options: + changes["max_concurrent"] = options["max_concurrent"] + if "expires" in options: + changes["expires"] = None if options["expires"] == "never" else options["expires"] + if "networks" in options: + changes["allowed_networks"] = options["networks"] + if "note" in options: + changes["note"] = options["note"] + if not changes: + raise ClientConfigError("nothing to change (see --help)") + _print(admin.update(args.name, changes, actor=actor), args.json) + case "rotate": + client, token = admin.rotate(args.name, actor=actor) + if args.json: + _print({"client": client, "token": token}, True) + else: + print(f"new TOKEN for {args.name} (shown only once; the old token no longer works):\n{token}") + case "delete": + if not args.yes and input(f"delete client {args.name!r}? [y/N] ").strip().lower() != "y": + print("aborted") + return 1 + admin.delete(args.name, actor=actor) + print(f"deleted {args.name}") + except (ClientConfigError, NotFound, Conflict) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/mcp_calculator/auth.py b/src/mcp_calculator/auth.py new file mode 100644 index 0000000..a25ef24 --- /dev/null +++ b/src/mcp_calculator/auth.py @@ -0,0 +1,153 @@ +"""Authentication of HTTP requests against the client registry. + +Order of checks: +1. A token (``Authorization: Bearer `` or ``X-API-Key: ``) must belong to an + active client and, if the client has ``allowed_networks``, come from one of them. + A presented but unknown token is always rejected, even from a trusted network. +2. Without a token, a ``network`` client whose ``allowed_networks`` contain the caller's IP + matches (the most specific network wins). +3. Otherwise the request is rejected. + +The caller's IP is ``scope["client"]``. Behind the OpenShift router it is taken from +``X-Forwarded-For``, but only if the direct peer is listed in ``MCP_CALC_TRUSTED_PROXIES`` +(uvicorn's proxy-header handling). + +The authenticated identity is stored in a context variable for the tool layer +(per-client limits, logs, metrics). stdio never authenticates: identity "local". +""" + +from __future__ import annotations + +import ipaddress +import json +import logging +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Literal + +from starlette.types import ASGIApp, Receive, Scope, Send + +from mcp_calculator import metrics +from mcp_calculator.clients import ClientRegistry, Snapshot, hash_token + +logger = logging.getLogger(__name__) + +# Endpoints for probes and scraping; they stay reachable without credentials. +PUBLIC_PATHS = frozenset({"/healthz", "/readyz", "/metrics"}) + +FailureReason = Literal["missing_credentials", "invalid_token", "disabled", "expired", "network_denied"] + + +@dataclass(frozen=True) +class Identity: + name: str + method: Literal["token", "network", "local", "anonymous"] + max_concurrent: int | None = None # None = server default + + @property + def limited(self) -> bool: + return self.method in ("token", "network", "anonymous") + + +LOCAL = Identity("local", "local") +ANONYMOUS = Identity("anonymous", "anonymous") + +current_identity: ContextVar[Identity] = ContextVar("current_identity", default=LOCAL) + + +@dataclass(frozen=True) +class Failure: + reason: FailureReason + client: str | None = None + + @property + def status(self) -> int: + return 401 if self.reason in ("missing_credentials", "invalid_token") else 403 + + @property + def message(self) -> str: + return { + "missing_credentials": "missing API token (Authorization: Bearer or X-API-Key)", + "invalid_token": "invalid API token", + "disabled": "client is disabled", + "expired": "API token has expired", + "network_denied": "API token is not allowed from this network", + }[self.reason] + + +def authenticate(snapshot: Snapshot, token: str | None, ip: str | None) -> Identity | Failure: + if token: + client = snapshot.by_hash.get(hash_token(token)) + if client is None: + return Failure("invalid_token") + status = client.status() + if status != "active": + return Failure(status, client.name) + if not client.allows_ip(ip): + return Failure("network_denied", client.name) + return Identity(client.name, "token", client.max_concurrent) + + matches = [c for c in snapshot.network_clients if c.status() == "active" and c.allows_ip(ip)] + if not matches: + return Failure("missing_credentials") + address = ipaddress.ip_address(ip) # allows_ip() succeeded, so ip is valid + best = max(matches, key=lambda c: max(n.prefixlen for n in c.allowed_networks if address in n)) + return Identity(best.name, "network", best.max_concurrent) + + +def extract_token(headers: list[tuple[bytes, bytes]]) -> str | None: + values = dict(headers) + authorization = values.get(b"authorization", b"") + scheme, _, credentials = authorization.partition(b" ") + if scheme.lower() == b"bearer" and credentials.strip(): + return credentials.strip().decode("latin-1") + api_key = values.get(b"x-api-key", b"").strip() + return api_key.decode("latin-1") if api_key else None + + +class AuthMiddleware: + """Rejects unauthenticated requests and records the identity of the others.""" + + def __init__(self, app: ASGIApp, registry: ClientRegistry) -> None: + self.app = app + self.registry = registry + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope["path"] in PUBLIC_PATHS: + await self.app(scope, receive, send) + return + ip = scope["client"][0] if scope.get("client") else None + result = authenticate(self.registry.snapshot(), extract_token(scope.get("headers") or []), ip) + if isinstance(result, Failure): + metrics.AUTH_FAILURES.labels(result.reason).inc() + logger.warning("auth rejected: reason=%s client=%s ip=%s path=%s", result.reason, result.client or "-", ip, scope["path"]) + await _reject(send, result) + return + token = current_identity.set(result) + try: + await self.app(scope, receive, send) + finally: + current_identity.reset(token) + + +class AnonymousMiddleware: + """HTTP without any configured client: everybody is 'anonymous' (still subject to limits).""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + token = current_identity.set(ANONYMOUS) + try: + await self.app(scope, receive, send) + finally: + current_identity.reset(token) + + +async def _reject(send: Send, failure: Failure) -> None: + body = json.dumps({"error": failure.reason, "message": failure.message}).encode() + headers = [(b"content-type", b"application/json")] + if failure.status == 401: + headers.append((b"www-authenticate", b"Bearer")) + await send({"type": "http.response.start", "status": failure.status, "headers": headers}) + await send({"type": "http.response.body", "body": body}) diff --git a/src/mcp_calculator/clients.py b/src/mcp_calculator/clients.py new file mode 100644 index 0000000..b3900e0 --- /dev/null +++ b/src/mcp_calculator/clients.py @@ -0,0 +1,359 @@ +"""API clients: model, validation, token hashing and the shared JSON file. + +The clients file lives on a shared (RWX) volume so that every replica reads the +same data and the admin API / CLI can change it at runtime: + + { + "version": 1, + "clients": { + "opencode-team-a": {"token_sha256": "...", "token_hint": "mcpc_AbCd", "max_concurrent": 4}, + "legacy-batch": {"auth": "network", "allowed_networks": ["10.131.2.15/32"]} + } + } + +Only SHA-256 hashes of tokens are stored; the plain token is shown once when it +is created. Tokens are 256-bit random values, so an unsalted hash is sufficient. + +Writes take an exclusive POSIX lock (``fcntl.lockf``, works on NFS/CephFS) and +replace the file atomically; readers never see a half-written file. +""" + +from __future__ import annotations + +import fcntl +import hashlib +import ipaddress +import json +import logging +import os +import re +import secrets +import tempfile +import threading +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field, replace +from datetime import UTC, date, datetime +from pathlib import Path +from typing import Any, Literal + +logger = logging.getLogger(__name__) + +TOKEN_PREFIX = "mcpc_" +FILE_VERSION = 1 +NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +_EDITABLE = {"enabled", "expires", "max_concurrent", "allowed_networks", "note"} + +AuthMethod = Literal["token", "network"] +Network = ipaddress.IPv4Network | ipaddress.IPv6Network + + +class ClientConfigError(ValueError): + """Invalid client data or clients file; the message is safe to show to an admin.""" + + +def new_token() -> str: + return TOKEN_PREFIX + secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def now_iso() -> str: + return datetime.now(UTC).isoformat(timespec="seconds") + + +@dataclass(frozen=True) +class Client: + name: str + auth: AuthMethod = "token" + token_sha256: str | None = None + token_hint: str | None = None + enabled: bool = True + expires: date | None = None # valid through this day (UTC) + max_concurrent: int | None = None # None = server default, 0 = unlimited + allowed_networks: tuple[Network, ...] = () + note: str = "" + created: str | None = None + updated: str | None = None + source: Literal["file", "env"] = field(default="file", compare=False) + + def status(self, today: date | None = None) -> Literal["active", "disabled", "expired"]: + if not self.enabled: + return "disabled" + if self.expires is not None and (today or datetime.now(UTC).date()) > self.expires: + return "expired" + return "active" + + def allows_ip(self, ip: str | None) -> bool: + if not self.allowed_networks: + return True + if ip is None: + return False + try: + address = ipaddress.ip_address(ip) + except ValueError: + return False + return any(address in network for network in self.allowed_networks) + + def to_dict(self, *, include_hash: bool = True) -> dict[str, Any]: + data = asdict(self) + data.pop("name") + data.pop("source") + data["expires"] = self.expires.isoformat() if self.expires else None + data["allowed_networks"] = [str(n) for n in self.allowed_networks] + if not include_hash: + data.pop("token_sha256") + return {k: v for k, v in data.items() if v is not None and v != "" and v != []} + + def public_view(self) -> dict[str, Any]: + """Representation for listings: no hash, plus the computed status.""" + return {"name": self.name, "status": self.status(), "source": self.source, **self.to_dict(include_hash=False)} + + @classmethod + def from_dict(cls, name: str, data: dict[str, Any], *, source: Literal["file", "env"] = "file") -> Client: + if not isinstance(data, dict): + raise ClientConfigError(f"client {name!r}: entry must be an object") + unknown = set(data) - {f for f in cls.__dataclass_fields__ if f not in ("name", "source")} + if unknown: + raise ClientConfigError(f"client {name!r}: unknown field(s) {sorted(unknown)}") + auth = data.get("auth", "token") + client = cls( + name=validate_name(name), + auth=auth, + token_sha256=data.get("token_sha256"), + token_hint=data.get("token_hint"), + enabled=_bool_field(name, "enabled", data.get("enabled", True)), + expires=_parse_expires(name, data.get("expires")), + max_concurrent=_limit_field(name, data.get("max_concurrent")), + allowed_networks=_parse_networks(name, data.get("allowed_networks", [])), + note=str(data.get("note", "")), + created=data.get("created"), + updated=data.get("updated"), + source=source, + ) + client.validate() + return client + + def validate(self) -> None: + if self.auth not in ("token", "network"): + raise ClientConfigError(f"client {self.name!r}: auth must be 'token' or 'network'") + if self.auth == "token" and not re.fullmatch(r"[0-9a-f]{64}", self.token_sha256 or ""): + raise ClientConfigError(f"client {self.name!r}: token clients need a 64-character hex token_sha256") + if self.auth == "network": + if self.token_sha256: + raise ClientConfigError(f"client {self.name!r}: network clients must not have a token") + if not self.allowed_networks: + raise ClientConfigError(f"client {self.name!r}: network clients need allowed_networks") + + def with_changes(self, changes: dict[str, Any]) -> Client: + unknown = set(changes) - _EDITABLE + if unknown: + raise ClientConfigError(f"cannot change field(s) {sorted(unknown)}; editable: {sorted(_EDITABLE)}") + updated = replace( + self, + enabled=_bool_field(self.name, "enabled", changes.get("enabled", self.enabled)), + expires=_parse_expires(self.name, changes["expires"]) if "expires" in changes else self.expires, + max_concurrent=_limit_field(self.name, changes["max_concurrent"]) if "max_concurrent" in changes else self.max_concurrent, + allowed_networks=_parse_networks(self.name, changes["allowed_networks"]) if "allowed_networks" in changes else self.allowed_networks, + note=str(changes.get("note", self.note)), + updated=now_iso(), + ) + updated.validate() + return updated + + +def validate_name(name: str) -> str: + if not isinstance(name, str) or not NAME_PATTERN.match(name): + raise ClientConfigError(f"invalid client name {name!r}: use 1-64 characters a-z 0-9 . _ - (starting with a letter or digit)") + return name + + +def _bool_field(name: str, key: str, value: Any) -> bool: + if not isinstance(value, bool): + raise ClientConfigError(f"client {name!r}: {key} must be true or false") + return value + + +def _limit_field(name: str, value: Any) -> int | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ClientConfigError(f"client {name!r}: max_concurrent must be an integer >= 0 (0 = unlimited) or null") + return value + + +def _parse_expires(name: str, value: Any) -> date | None: + if value in (None, ""): + return None + try: + return date.fromisoformat(str(value)) + except ValueError: + raise ClientConfigError(f"client {name!r}: expires must be a date like 2027-03-31") from None + + +def _parse_networks(name: str, value: Any) -> tuple[Network, ...]: + if not isinstance(value, list): + raise ClientConfigError(f"client {name!r}: allowed_networks must be a list of IPs/CIDRs") + try: + return tuple(ipaddress.ip_network(str(v), strict=False) for v in value) + except ValueError as exc: + raise ClientConfigError(f"client {name!r}: invalid network in allowed_networks: {exc}") from None + + +def clients_from_env(entries: list[str]) -> dict[str, Client]: + """Static clients from MCP_CALC_AUTH_TOKENS (name:token, validated in config).""" + clients = {} + for entry in entries: + name, _, token = entry.partition(":") + clients[name] = Client(name=validate_name(name), token_sha256=hash_token(token), token_hint=token[:9], source="env") + return clients + + +# --------------------------------------------------------------------------- file + + +class ClientFile: + """Reads and atomically rewrites the clients file under an exclusive lock.""" + + def __init__(self, path: str | os.PathLike[str]) -> None: + self.path = Path(path) + self.lock_path = self.path.with_name(self.path.name + ".lock") + + def fingerprint(self) -> tuple[int, int] | None: + try: + stat = self.path.stat() + except FileNotFoundError: + return None + return (stat.st_mtime_ns, stat.st_size) + + def read(self) -> dict[str, Client]: + if not self.path.exists(): + return {} + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ClientConfigError(f"{self.path}: invalid JSON ({exc})") from None + if not isinstance(raw, dict) or not isinstance(raw.get("clients", {}), dict): + raise ClientConfigError(f"{self.path}: expected an object with a 'clients' object") + if raw.get("version", FILE_VERSION) != FILE_VERSION: + raise ClientConfigError(f"{self.path}: unsupported version {raw.get('version')!r}") + clients = {name: Client.from_dict(name, data) for name, data in raw.get("clients", {}).items()} + hashes = [c.token_sha256 for c in clients.values() if c.token_sha256] + if len(hashes) != len(set(hashes)): + raise ClientConfigError(f"{self.path}: two clients share the same token") + return clients + + @contextmanager + def update(self) -> Iterator[dict[str, Client]]: + """Lock, read, let the caller mutate the dict, write back atomically.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + with open(self.lock_path, "a+") as lock: + fcntl.lockf(lock, fcntl.LOCK_EX) + try: + clients = self.read() + yield clients + self._write(clients) + finally: + fcntl.lockf(lock, fcntl.LOCK_UN) + + def _write(self, clients: dict[str, Client]) -> None: + payload = {"version": FILE_VERSION, "clients": {name: c.to_dict() for name, c in sorted(clients.items())}} + fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, ensure_ascii=False) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp, 0o640) + os.replace(tmp, self.path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + +# --------------------------------------------------------------------------- registry + + +@dataclass(frozen=True) +class Snapshot: + """Immutable view used for authentication.""" + + clients: dict[str, Client] + by_hash: dict[str, Client] + network_clients: tuple[Client, ...] + loaded_at: float + + @classmethod + def build(cls, clients: dict[str, Client]) -> Snapshot: + return cls( + clients=clients, + by_hash={c.token_sha256: c for c in clients.values() if c.token_sha256}, + network_clients=tuple(c for c in clients.values() if c.auth == "network"), + loaded_at=time.time(), + ) + + +class ClientRegistry: + """Current clients from the file (hot-reloaded) plus static clients from the environment. + + The file is checked at most every ``reload_seconds`` (a cheap ``stat``). If a changed + file is invalid, the previous state stays active and an error is logged, so a typo + never locks everybody out, and never grants access either. + """ + + def __init__(self, file: ClientFile | None, static: dict[str, Client], reload_seconds: float = 5.0) -> None: + self.file = file + self.static = static + self.reload_seconds = reload_seconds + self._lock = threading.Lock() + self._fingerprint: tuple[int, int] | None = None + self._checked_at = 0.0 + self._snapshot = Snapshot.build(dict(static)) + if file is not None: + self._load(initial=True) + + @property + def enabled(self) -> bool: + """Authentication is active when any client source is configured.""" + return self.file is not None or bool(self.static) + + def snapshot(self) -> Snapshot: + if self.file is not None and time.monotonic() - self._checked_at >= self.reload_seconds: + self._load(initial=False) + return self._snapshot + + def reload_now(self) -> None: + if self.file is not None: + self._load(initial=False, force=True) + + def _load(self, *, initial: bool, force: bool = False) -> None: + assert self.file is not None + with self._lock: + self._checked_at = time.monotonic() + fingerprint = self.file.fingerprint() + if not force and not initial and fingerprint == self._fingerprint: + return + try: + from_file = self.file.read() + except ClientConfigError as exc: + if initial: + raise + logger.error("clients file not reloaded, keeping previous state: %s", exc) + self._fingerprint = fingerprint # do not retry until it changes again + return + clash = set(from_file) & set(self.static) + if clash: + message = f"client name(s) defined both in the clients file and MCP_CALC_AUTH_TOKENS: {sorted(clash)}" + if initial: + raise ClientConfigError(message) + logger.error("clients file not reloaded, keeping previous state: %s", message) + self._fingerprint = fingerprint + return + self._snapshot = Snapshot.build({**self.static, **from_file}) + self._fingerprint = fingerprint + if not initial: + logger.info("clients file reloaded: %d client(s)", len(from_file)) diff --git a/src/mcp_calculator/config.py b/src/mcp_calculator/config.py new file mode 100644 index 0000000..88f58cc --- /dev/null +++ b/src/mcp_calculator/config.py @@ -0,0 +1,198 @@ +"""Runtime configuration. + +Every setting can be given as environment variable (prefix ``MCP_CALC_``) or as +CLI flag. CLI flags win over environment variables, which win over defaults. +""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass, field, fields +from typing import Literal + +ENV_PREFIX = "MCP_CALC_" + +Transport = Literal["stdio", "http"] +ExecutorMode = Literal["process", "inline"] + + +def _csv(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +def _bool(value: str) -> bool: + normalized = value.strip().lower() + if normalized in ("1", "true", "yes", "on"): + return True + if normalized in ("0", "false", "no", "off"): + return False + raise ValueError("expected true/false") + + +@dataclass(frozen=True) +class Limits: + """Guards applied to every computation. Shipped to worker processes.""" + + max_input_length: int = 5_000 + max_output_length: int = 20_000 + max_precision: int = 1_000 + max_matrix_size: int = 30 + max_data_points: int = 1_000_000 + max_list_items: int = 10_000 + + +@dataclass(frozen=True) +class Settings: + transport: Transport = "stdio" + + # HTTP transport + host: str = "127.0.0.1" + port: int = 8080 + path: str = "/mcp" + allowed_hosts: list[str] = field(default_factory=list) + allowed_origins: list[str] = field(default_factory=list) + trusted_proxies: list[str] = field(default_factory=list) + max_request_body_bytes: int = 1_048_576 + max_connections: int = 200 + metrics: bool = True + + # Authentication (HTTP only; stdio never authenticates) + clients_file: str = "" + clients_reload_seconds: float = 5.0 + auth_tokens: list[str] = field(default_factory=list) + audit_file: str = "" + + # Admin API (separate port, only started when admin tokens are configured) + admin_tokens: list[str] = field(default_factory=list) + admin_host: str = "127.0.0.1" + admin_port: int = 9090 + + # Execution + executor: ExecutorMode = "process" + workers: int = 2 + max_queue: int = 20 + max_queue_wait_seconds: float = 30.0 + client_max_concurrent: int = 10 + timeout_seconds: float = 10.0 + max_tasks_per_worker: int = 200 + memory_limit_mb: int = 512 + + limits: Limits = field(default_factory=Limits) + + # Logging + log_level: str = "INFO" + log_format: Literal["text", "json"] = "text" + + +# name -> (parser, help text). Names map to MCP_CALC_ and --. +_OPTIONS: dict[str, tuple[type | object, str]] = { + "transport": (str, "stdio | http"), + "host": (str, "HTTP bind address (use 0.0.0.0 inside containers)"), + "port": (int, "HTTP port"), + "path": (str, "HTTP path of the MCP endpoint"), + "allowed_hosts": (_csv, "comma-separated Host header values; enables DNS-rebinding protection"), + "allowed_origins": (_csv, "comma-separated Origin header values (browser clients only)"), + "trusted_proxies": (_csv, "comma-separated IPs/CIDRs of proxies whose X-Forwarded-For is trusted (e.g. the OpenShift router)"), + "max_request_body_bytes": (int, "maximum HTTP request body size"), + "max_connections": (int, "concurrent HTTP connections; a new connection that would reach this number is answered with 503"), + "metrics": (_bool, "expose Prometheus metrics at /metrics (HTTP only): true | false"), + "clients_file": (str, "JSON file with API clients (tokens, limits, networks); enables authentication"), + "clients_reload_seconds": (float, "how often the clients file is checked for changes"), + "auth_tokens": (_csv, "static bearer tokens as name:token (comma-separated); simple alternative to clients_file"), + "audit_file": (str, "audit log of client changes (default: audit.log next to clients_file)"), + "admin_tokens": (_csv, "admin API tokens as name:token; the admin API only starts when set"), + "admin_host": (str, "admin API bind address (127.0.0.1 = only via oc port-forward/exec)"), + "admin_port": (int, "admin API port"), + "executor": (str, "process (isolated worker pool, default) | inline (no isolation, dev/tests only)"), + "workers": (int, "number of worker processes"), + "max_queue": (int, "computations that may wait for a free worker; beyond that requests are rejected as busy"), + "max_queue_wait_seconds": (float, "maximum time a computation waits for a free worker before it is rejected as busy"), + "client_max_concurrent": (int, "default limit of concurrent computations per client (0 = unlimited); overridable per client"), + "timeout_seconds": (float, "hard timeout per computation"), + "max_tasks_per_worker": (int, "recycle a worker after N tasks (0 = never)"), + "memory_limit_mb": (int, "extra memory a computation may allocate per worker (Linux only, 0 = off)"), + "max_input_length": (int, "maximum characters per expression"), + "max_output_length": (int, "results longer than this are truncated"), + "max_precision": (int, "maximum significant digits for numeric evaluation"), + "max_matrix_size": (int, "maximum rows/columns of a matrix"), + "max_data_points": (int, "maximum values per statistics data set"), + "max_list_items": (int, "maximum items in other list inputs"), + "log_level": (str, "DEBUG | INFO | WARNING | ERROR"), + "log_format": (str, "text | json"), +} + +_LIMIT_FIELDS = {f.name for f in fields(Limits)} + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="mcp-calculator", + description="MCP server with exact math tools (stdio or Streamable HTTP).", + epilog=f"Every option can also be set via environment variable {ENV_PREFIX}