initial commit

This commit is contained in:
Thomas Adamek 2026-09-15 20:38:16 +02:00
commit 45214ff6f3
54 changed files with 9253 additions and 0 deletions

50
.env.example Normal file
View file

@ -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

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
build/
dist/
.pytest_cache/
.coverage
htmlcov/
.env
.ruff_cache/

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.12

130
BACKLOG.md Normal file
View file

@ -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/<pid>/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 |

523
README.md Normal file
View file

@ -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://<host>: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 <name>`. Das Token wird im Header `Authorization: Bearer <token>`
mitgeschickt, alternativ als `X-API-Key: <token>`. 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 <token>"
},
"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 <token>"
# 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_<NAME>` oder per CLI-Flag `--<name>` 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=<name> client=<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/<bereich>.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("<name>", "<modul>.<funktion>", **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_<bereich>.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.

248
docs/auth.md Normal file
View file

@ -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:<langes-zufälliges-token>` 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 <admin-token>`.
| 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 <name>`. 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 <neues Datum>`.
**Ü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:<name>` (Admin-API) oder `cli:<user>` (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).

154
docs/memory.md Normal file
View file

@ -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.

236
docs/tools.md Normal file
View file

@ -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<X≤upper), `quantile`, `interval`, `summary` |
| `parameters` | z. B. `{"n": 10, "p": 0.3}` |
| `x`, `lower`, `upper`, `q`, `confidence` | je nach Operation |
Das Feld `meaning` sagt genau, was berechnet wurde. Bei diskreten Verteilungen z. B.
`"P(X > 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.

View file

@ -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())

44
pyproject.toml Normal file
View file

@ -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"]

596
requirements.lock Normal file
View file

@ -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

141
scripts/measure_memory.py Normal file
View file

@ -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()

View file

@ -0,0 +1,3 @@
"""MCP server providing exact, deterministic math tools for LLMs."""
__version__ = "1.2.0"

View file

@ -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()

214
src/mcp_calculator/admin.py Normal file
View file

@ -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"]),
]
)

View file

@ -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())

153
src/mcp_calculator/auth.py Normal file
View file

@ -0,0 +1,153 @@
"""Authentication of HTTP requests against the client registry.
Order of checks:
1. A token (``Authorization: Bearer <token>`` or ``X-API-Key: <token>``) 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 <token> 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})

View file

@ -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))

View file

@ -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_<NAME> and --<name-with-dashes>.
_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}<OPTION>, "
f"e.g. {ENV_PREFIX}TRANSPORT=http.",
)
for name, (_, help_text) in _OPTIONS.items():
parser.add_argument(f"--{name.replace('_', '-')}", dest=name, default=None, help=help_text)
return parser
def load_settings(argv: list[str] | None = None, env: dict[str, str] | None = None) -> Settings:
"""Build settings from defaults, environment and CLI arguments."""
env = os.environ if env is None else env
args = vars(_build_parser().parse_args(argv if argv is not None else []))
raw: dict[str, object] = {}
for name, (convert, _) in _OPTIONS.items():
value = args.get(name)
if value is None:
value = env.get(ENV_PREFIX + name.upper())
if value is None:
continue
try:
raw[name] = convert(value) # type: ignore[operator]
except ValueError as exc:
raise SystemExit(f"Invalid value for {name}: {value!r} ({exc})") from exc
limits = Limits(**{k: raw.pop(k) for k in list(raw) if k in _LIMIT_FIELDS})
settings = Settings(limits=limits, **raw) # type: ignore[arg-type]
_validate(settings)
return settings
def _validate(s: Settings) -> None:
errors = []
if s.transport not in ("stdio", "http"):
errors.append(f"transport must be 'stdio' or 'http', got {s.transport!r}")
if s.executor not in ("process", "inline"):
errors.append(f"executor must be 'process' or 'inline', got {s.executor!r}")
if s.log_format not in ("text", "json"):
errors.append(f"log_format must be 'text' or 'json', got {s.log_format!r}")
if not s.path.startswith("/"):
errors.append("path must start with '/'")
if s.workers < 1:
errors.append("workers must be >= 1")
if s.max_queue < 0:
errors.append("max_queue must be >= 0")
if s.max_queue_wait_seconds <= 0:
errors.append("max_queue_wait_seconds must be > 0")
if s.client_max_concurrent < 0:
errors.append("client_max_concurrent must be >= 0")
if s.max_connections < 1:
errors.append("max_connections must be >= 1")
if s.clients_reload_seconds <= 0:
errors.append("clients_reload_seconds must be > 0")
for name in ("auth_tokens", "admin_tokens"):
for entry in getattr(s, name):
label, sep, secret = entry.partition(":")
if not sep or not label or len(secret) < 16:
errors.append(f"{name} entries must look like name:token with a token of at least 16 characters")
break
if s.admin_tokens and s.admin_port == s.port:
errors.append("admin_port must differ from port")
if s.admin_tokens and not s.clients_file:
errors.append("admin_tokens need clients_file (the admin API manages that file)")
if s.timeout_seconds <= 0:
errors.append("timeout_seconds must be > 0")
if errors:
raise SystemExit("Invalid configuration:\n " + "\n ".join(errors))

View file

@ -0,0 +1,272 @@
"""Runs math operations in isolated worker processes with a hard timeout.
Symbolic math can take unbounded time or memory (``solve`` on a nasty system,
``9^9^9^9``). Each computation therefore runs in a pool of worker processes:
* a computation exceeding the timeout gets its worker killed and replaced,
* on Linux every worker gets an address-space limit (``RLIMIT_AS``) of
``memory_limit_mb`` on top of its size after loading the libraries, so a
runaway computation dies with MemoryError instead of taking down the pod,
* BLAS/OpenMP run single-threaded per worker (parallelism = number of workers),
* workers are recycled after N tasks to bound memory growth (SymPy caches),
* at most ``workers + max_queue`` computations are accepted at once; further
requests are rejected immediately as "busy" instead of piling up (backpressure),
* a computation waits at most ``max_queue_wait_seconds`` for a free worker; the
queue is our own (a semaphore), so pebble only ever receives tasks it can start
immediately and the timeout measures pure computation time,
* if the caller is cancelled (client disconnected), a waiting computation leaves
the queue and a running one is stopped by terminating its worker.
Tasks are addressed by name (``"algebra.evaluate"``) instead of function objects.
That keeps SymPy, SciPy and friends out of the server process: only the workers
import ``mcp_calculator.ops.*`` (see docs/memory.md).
The ``inline`` mode runs operations in a thread of the server process. It has no
isolation and no enforceable timeout and is only meant for development and tests.
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import importlib
import logging
import multiprocessing
import os
import resource
import sys
from concurrent.futures.process import BrokenProcessPool
from typing import Any
from pebble import ProcessExpired, ProcessPool
from mcp_calculator import metrics
from mcp_calculator.config import Limits, Settings
from mcp_calculator.ops.context import MathError, set_limits
logger = logging.getLogger(__name__)
# Modules under mcp_calculator.ops whose functions may be called as tasks.
TASK_MODULES = ("algebra", "calculus", "linalg", "number_theory", "stats", "units")
class ComputationError(Exception):
"""Raised to the tool layer; the message is shown to the model.
``kind`` classifies the failure for metrics and logs:
error (math/input error), timeout, busy (queue full), crashed (worker died / pool failure).
"""
def __init__(self, message: str, kind: str = "error") -> None:
super().__init__(message)
self.kind = kind
def _noop() -> None:
"""Warm-up task: makes the pool spawn its workers at start instead of on the first request."""
# One BLAS/OpenMP thread per worker: parallelism comes from the worker count, and
# per-core BLAS threads would oversubscribe the pod's CPU limit and inflate the
# virtual address space. Must be set before NumPy is imported.
_SINGLE_THREAD_ENV = ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "MKL_NUM_THREADS")
def _init_worker(limits: Limits, memory_limit_mb: int) -> None:
set_limits(limits)
for var in _SINGLE_THREAD_ENV:
os.environ.setdefault(var, "1")
# Import the math libraries once per worker instead of on the first request.
for module in TASK_MODULES:
importlib.import_module(f"mcp_calculator.ops.{module}")
if memory_limit_mb > 0 and sys.platform.startswith("linux"):
_limit_address_space(memory_limit_mb)
def _limit_address_space(extra_mb: int) -> None:
"""Allow ``extra_mb`` of address space on top of what the loaded libraries already use.
RLIMIT_AS counts virtual memory, which for NumPy/SciPy is far above the RSS. An
absolute limit set before the imports could make the imports themselves fail, so
the limit is applied afterwards, relative to the current size.
"""
try:
with open("/proc/self/statm") as f:
current = int(f.read().split()[0]) * os.sysconf("SC_PAGE_SIZE")
limit = current + extra_mb * 1024 * 1024
resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
except (OSError, ValueError) as exc:
logger.warning("could not set memory limit for worker: %s", exc)
def _resolve(task: str) -> Any:
"""'algebra.evaluate' -> mcp_calculator.ops.algebra.evaluate (only public functions of TASK_MODULES)."""
module, _, name = task.partition(".")
if module not in TASK_MODULES or not name.isidentifier() or name.startswith("_"):
raise ValueError(f"unknown task {task!r}")
return getattr(importlib.import_module(f"mcp_calculator.ops.{module}"), name)
def _guarded(task: str, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Executed inside the worker. Converts every failure into a picklable result."""
try:
return {"ok": True, "value": _resolve(task)(**kwargs)}
except MathError as exc:
return {"ok": False, "error": str(exc)}
except MemoryError:
return {"ok": False, "error": "computation exceeded the memory limit"}
except RecursionError:
return {"ok": False, "error": "computation too deeply nested (recursion limit)"}
except ZeroDivisionError:
return {"ok": False, "error": "division by zero"}
except (ValueError, TypeError, NotImplementedError, ArithmeticError, KeyError, IndexError, AttributeError) as exc:
# SymPy/SciPy signal unsupported input with these; the message is useful to the model.
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"[:2000]}
class Executor:
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._pool: ProcessPool | None = None
self._warmup: concurrent.futures.Future | None = None
self._inflight = 0 # running + waiting computations; only touched from the event loop
self._slots: asyncio.Semaphore | None = None # free workers; bound to the running event loop
self._slots_loop: asyncio.AbstractEventLoop | None = None
set_limits(settings.limits) # for inline mode and parsing in the server process
metrics.WORKERS.set(settings.workers)
metrics.CAPACITY.set(self.capacity)
@property
def settings(self) -> Settings:
return self._settings
@property
def mode(self) -> str:
return self._settings.executor
@property
def capacity(self) -> int:
"""Maximum number of computations accepted at the same time."""
return self._settings.workers + self._settings.max_queue
@property
def inflight(self) -> int:
return self._inflight
def start(self) -> None:
if self.mode != "process" or self._pool is not None:
return
self._pool = ProcessPool(
max_workers=self._settings.workers,
max_tasks=self._settings.max_tasks_per_worker,
initializer=_init_worker,
initargs=(self._settings.limits, self._settings.memory_limit_mb),
# 'spawn' is safe with the asyncio/uvicorn threads of the parent process.
context=multiprocessing.get_context("spawn"),
)
# Pebble spawns its workers lazily on the first task; do that now, not on the first request.
# Readiness waits for this task, so no traffic arrives before the workers can compute.
self._warmup = self._pool.schedule(_noop)
logger.info(
"worker pool started (%d workers, queue %d, timeout %.1fs)",
self._settings.workers,
self._settings.max_queue,
self._settings.timeout_seconds,
)
def stop(self, graceful: bool = True) -> None:
if self._pool is None:
return
# close() lets running computations finish (bounded by their timeout); stop() kills them.
if graceful and self._pool.active:
self._pool.close()
try:
self._pool.join(timeout=self._settings.timeout_seconds + 1)
except TimeoutError:
graceful = False
if not graceful or self._pool.active:
self._pool.stop()
self._pool.join(timeout=5)
self._pool = None
@property
def healthy(self) -> bool:
"""Ready to compute: pool running and workers warmed up (libraries loaded)."""
if self.mode == "inline":
return True
return self._pool is not None and self._pool.active and self._warmup is not None and self._warmup.done()
def _schedule(self, task: str, kwargs: dict[str, Any], timeout: float) -> concurrent.futures.Future:
if self._pool is None or not self._pool.active:
self.stop(graceful=False)
self.start()
assert self._pool is not None
try:
return self._pool.schedule(_guarded, args=(task, kwargs), timeout=timeout)
except RuntimeError as exc: # pebble: "Unexpected error within the Pool"
raise BrokenProcessPool(str(exc)) from exc
async def run(self, task: str, **kwargs: Any) -> dict[str, Any]:
"""Run ``task`` (e.g. ``"algebra.evaluate"``) with keyword arguments and return its result dict."""
if self._inflight >= self.capacity:
raise ComputationError(
f"server busy: {self._inflight} computations are running or queued (limit {self.capacity}). Retry in a few seconds.",
kind="busy",
)
self._inflight += 1
metrics.INFLIGHT.set(self._inflight)
try:
return await self._run(task, kwargs)
finally:
self._inflight -= 1
metrics.INFLIGHT.set(self._inflight)
def _worker_slots(self) -> asyncio.Semaphore:
loop = asyncio.get_running_loop()
if self._slots is None or self._slots_loop is not loop:
self._slots = asyncio.Semaphore(self._settings.workers)
self._slots_loop = loop
return self._slots
async def _run(self, task: str, kwargs: dict[str, Any]) -> dict[str, Any]:
slots = self._worker_slots()
wait = self._settings.max_queue_wait_seconds
try:
async with asyncio.timeout(wait):
await slots.acquire()
except TimeoutError:
raise ComputationError(
f"server busy: no worker became free within {wait:g}s. Retry in a few seconds.",
kind="busy",
) from None
try:
return await self._compute(task, kwargs)
finally:
slots.release()
async def _compute(self, task: str, kwargs: dict[str, Any]) -> dict[str, Any]:
timeout = self._settings.timeout_seconds
try:
if self.mode == "inline":
outcome = await asyncio.wait_for(asyncio.to_thread(_guarded, task, kwargs), timeout)
else:
outcome = await asyncio.wrap_future(self._schedule(task, kwargs, timeout))
except TimeoutError: # asyncio and concurrent.futures timeouts are aliases since 3.11
raise ComputationError(
f"computation aborted after {timeout:g}s (timeout). Simplify the problem, "
"use numeric methods, or split it into smaller steps.",
kind="timeout",
) from None
except ProcessExpired as exc:
raise ComputationError(f"computation aborted: worker process died ({exc}), probably out of memory", kind="crashed") from None
except BrokenProcessPool:
logger.error("worker pool broke; restarting it")
metrics.POOL_RESTARTS.inc()
self.stop(graceful=False)
self.start()
raise ComputationError("internal worker pool error; the pool was restarted, please retry", kind="crashed") from None
if not outcome["ok"]:
raise ComputationError(outcome["error"])
return outcome["value"]

View file

@ -0,0 +1,234 @@
"""Streamable HTTP transport: ASGI app, authentication, health, metrics and the admin API.
Endpoints (main port):
POST {path} MCP Streamable HTTP endpoint (stateless, JSON responses)
GET /healthz liveness - process is up
GET /readyz readiness - workers are warm
GET /metrics Prometheus metrics (unless MCP_CALC_METRICS=false)
Admin API (own port, only when MCP_CALC_ADMIN_TOKENS is set): see ``admin.py``.
Middleware order for a request: uvicorn (connection limit, proxy headers)
→ AuthMiddleware (identity or 401/403) → CancelOnDisconnect → MCP SDK app.
The server is stateless (no MCP sessions), so any number of replicas can run
behind a load balancer without sticky sessions.
"""
from __future__ import annotations
import asyncio
import collections
import contextlib
import logging
from pathlib import Path
import anyio
import uvicorn
from mcp.server.mcpserver import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from mcp_calculator import __version__, metrics
from mcp_calculator.admin import AuditLog, ClientAdmin, build_admin_app
from mcp_calculator.auth import AnonymousMiddleware, AuthMiddleware
from mcp_calculator.clients import ClientConfigError, ClientFile, ClientRegistry, clients_from_env
from mcp_calculator.config import Settings
from mcp_calculator.executor import Executor
logger = logging.getLogger(__name__)
_LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
class CancelOnDisconnect:
"""Cancels the MCP request when the client disconnects before the response is sent.
Without this, a computation keeps running (and occupying a worker) until its timeout
even though nobody waits for the result any more. The request body is read first
(bounded by ``max_body``); afterwards a watcher waits for ``http.disconnect`` while
the app runs, and cancels the app if the response is not complete yet. Cancellation
propagates to the executor, which drops a queued computation or terminates the
worker of a running one.
"""
def __init__(self, app: ASGIApp, max_body: int) -> None:
self.app = app
self.max_body = max_body
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or scope["method"] != "POST":
await self.app(scope, receive, send)
return
buffered: collections.deque[Message] = collections.deque()
size = 0
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
size += len(message.get("body", b""))
if size > self.max_body:
await send({"type": "http.response.start", "status": 413, "headers": [(b"content-type", b"application/json")]})
await send({"type": "http.response.body", "body": b'{"error": "request body too large"}'})
return
buffered.append(message)
if not message.get("more_body", False):
break
disconnected = anyio.Event()
response_finishing = False
async def replay_receive() -> Message:
if buffered:
return buffered.popleft()
await disconnected.wait()
return {"type": "http.disconnect"}
async def tracking_send(message: Message) -> None:
nonlocal response_finishing
if message["type"] == "http.response.body" and not message.get("more_body", False):
response_finishing = True # set before sending: a disconnect after this point is normal
await send(message)
async with anyio.create_task_group() as tg:
async def watch_disconnect() -> None:
message = await receive() # the body is consumed; this only returns on disconnect
if message["type"] == "http.disconnect":
disconnected.set()
if not response_finishing:
logger.info("client disconnected before the response; cancelling the request")
tg.cancel_scope.cancel()
tg.start_soon(watch_disconnect)
await self.app(scope, replay_receive, tracking_send)
tg.cancel_scope.cancel() # response sent; stop the watcher
def build_registry(settings: Settings) -> ClientRegistry:
try:
file = ClientFile(settings.clients_file) if settings.clients_file else None
return ClientRegistry(file, clients_from_env(settings.auth_tokens), settings.clients_reload_seconds)
except ClientConfigError as exc:
raise SystemExit(f"Invalid client configuration: {exc}") from None
def _transport_security(settings: Settings) -> TransportSecuritySettings | None:
if settings.allowed_hosts:
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=settings.allowed_hosts,
allowed_origins=settings.allowed_origins,
)
if settings.host in _LOCAL_HOSTS:
return None # SDK default: DNS-rebinding protection for localhost
# Bound to all interfaces without a host allow-list (typical behind an ingress/route).
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
def build_app(mcp: MCPServer, settings: Settings, executor: Executor, registry: ClientRegistry) -> ASGIApp:
@mcp.custom_route("/healthz", methods=["GET"], include_in_schema=False)
async def healthz(_: Request) -> JSONResponse:
return JSONResponse({"status": "ok", "version": __version__})
@mcp.custom_route("/readyz", methods=["GET"], include_in_schema=False)
async def readyz(_: Request) -> JSONResponse:
ready = executor.healthy
return JSONResponse({"status": "ready" if ready else "not ready", "executor": executor.mode}, status_code=200 if ready else 503)
if settings.metrics:
@mcp.custom_route("/metrics", methods=["GET"], include_in_schema=False)
async def metrics_endpoint(_: Request) -> Response:
if registry.enabled:
counts = collections.Counter(c.status() for c in registry.snapshot().clients.values())
for status in ("active", "disabled", "expired"):
metrics.CLIENTS.labels(status).set(counts.get(status, 0))
body, content_type = metrics.render()
return Response(body, media_type=content_type)
app: ASGIApp = mcp.streamable_http_app(
streamable_http_path=settings.path,
stateless_http=True,
json_response=True,
max_request_body_size=settings.max_request_body_bytes,
transport_security=_transport_security(settings),
host=settings.host,
)
app = CancelOnDisconnect(app, settings.max_request_body_bytes)
return AuthMiddleware(app, registry) if registry.enabled else AnonymousMiddleware(app)
class _MainServer(uvicorn.Server):
"""Stops the admin server and the worker pool as part of uvicorn's shutdown.
On SIGTERM/SIGINT uvicorn finishes in-flight requests and then re-raises the
signal, which ends the process immediately; code after ``serve()`` never runs.
Cleanup therefore happens here, after the last request and before the re-raise.
"""
def __init__(self, config: uvicorn.Config, executor: Executor, admin_server: uvicorn.Server | None) -> None:
super().__init__(config)
self._executor = executor
self._admin_server = admin_server
async def shutdown(self, sockets: list | None = None) -> None:
if self._admin_server is not None:
self._admin_server.should_exit = True
await super().shutdown(sockets)
await asyncio.to_thread(self._executor.stop)
logger.info("worker pool stopped")
class _AdminServer(uvicorn.Server):
"""Second uvicorn instance in the same process; signals are handled by the main server only."""
def capture_signals(self) -> contextlib.AbstractContextManager[None]: # type: ignore[override]
return contextlib.nullcontext()
def _uvicorn_config(app: ASGIApp, host: str, port: int, settings: Settings, **extra: object) -> uvicorn.Config:
return uvicorn.Config(
app,
host=host,
port=port,
log_config=None, # use our logging setup
server_header=False,
proxy_headers=bool(settings.trusted_proxies),
forwarded_allow_ips=settings.trusted_proxies,
**extra,
)
async def serve_http(mcp: MCPServer, settings: Settings, executor: Executor) -> None:
registry = build_registry(settings)
app = build_app(mcp, settings, executor, registry)
if registry.enabled:
logger.info("authentication enabled: %d client(s)", len(registry.snapshot().clients))
elif settings.host not in _LOCAL_HOSTS:
logger.warning("HTTP transport is reachable from the network without authentication (set MCP_CALC_CLIENTS_FILE)")
admin_server = None
if settings.admin_tokens:
audit_path = settings.audit_file or Path(settings.clients_file).with_name("audit.log")
admin = ClientAdmin(ClientFile(settings.clients_file), AuditLog(audit_path), on_change=registry.reload_now)
admin_app = build_admin_app(admin, settings.admin_tokens)
admin_server = _AdminServer(_uvicorn_config(admin_app, settings.admin_host, settings.admin_port, settings))
logger.info("admin API on http://%s:%d/admin/clients", settings.admin_host, settings.admin_port)
main_config = _uvicorn_config(
app,
settings.host,
settings.port,
settings,
limit_concurrency=settings.max_connections,
timeout_graceful_shutdown=int(settings.timeout_seconds + settings.max_queue_wait_seconds) + 5,
)
logger.info("serving MCP over HTTP on http://%s:%d%s", settings.host, settings.port, settings.path)
async with anyio.create_task_group() as tg:
if admin_server is not None:
tg.start_soon(admin_server.serve)
await _MainServer(main_config, executor, admin_server).serve()

View file

@ -0,0 +1,36 @@
"""Logging to stderr (stdout is the protocol channel in stdio mode)."""
from __future__ import annotations
import json
import logging
import sys
from datetime import UTC, datetime
class JsonFormatter(logging.Formatter):
"""One JSON object per line, for log aggregation."""
def format(self, record: logging.LogRecord) -> str:
entry = {
"time": datetime.fromtimestamp(record.created, tz=UTC).isoformat(timespec="milliseconds"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
entry["exception"] = self.formatException(record.exc_info)
return json.dumps(entry, ensure_ascii=False)
def setup_logging(level: str = "INFO", fmt: str = "text") -> None:
handler = logging.StreamHandler(sys.stderr)
if fmt == "json":
handler.setFormatter(JsonFormatter())
else:
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s"))
logging.basicConfig(level=level.upper(), handlers=[handler], force=True)
# The SDK logs every request at INFO; keep it quieter unless debugging.
if level.upper() != "DEBUG":
for noisy in ("mcp.server.lowlevel.server", "mcp.server.streamable_http", "mcp.server.streamable_http_manager"):
logging.getLogger(noisy).setLevel(logging.WARNING)

View file

@ -0,0 +1,62 @@
"""Prometheus metrics of the server process (exposed at ``/metrics`` in HTTP mode).
Uses a dedicated registry instead of the global default one, so creating several
servers (tests) never registers a metric twice. Process metrics (``process_*``:
CPU, RSS, open files) cover the server process only; the workers are separate
processes and are not included.
"""
from __future__ import annotations
from prometheus_client import (
CONTENT_TYPE_LATEST,
CollectorRegistry,
Counter,
Gauge,
Histogram,
Info,
PlatformCollector,
ProcessCollector,
generate_latest,
)
from mcp_calculator import __version__
REGISTRY = CollectorRegistry()
ProcessCollector(registry=REGISTRY)
PlatformCollector(registry=REGISTRY)
BUILD_INFO = Info("mcp_calc_build", "Version of mcp-calculator", registry=REGISTRY)
BUILD_INFO.info({"version": __version__})
TOOL_CALLS = Counter(
"mcp_calc_tool_calls",
"Tool calls by client and outcome: ok | error (math/input error) | timeout | busy (capacity, queue or client limit) "
"| cancelled (client disconnected) | crashed (worker died)",
["tool", "client", "status"],
registry=REGISTRY,
)
TOOL_DURATION = Histogram(
"mcp_calc_tool_duration_seconds",
"Tool call duration including queueing, all outcomes",
["tool"],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60),
registry=REGISTRY,
)
INFLIGHT = Gauge("mcp_calc_inflight_computations", "Computations currently running or waiting for a worker", registry=REGISTRY)
CAPACITY = Gauge("mcp_calc_capacity", "Maximum concurrent computations (workers + queue); beyond this requests are rejected", registry=REGISTRY)
WORKERS = Gauge("mcp_calc_workers", "Configured number of worker processes", registry=REGISTRY)
POOL_RESTARTS = Counter("mcp_calc_pool_restarts", "Worker pool restarts after an internal pool failure", registry=REGISTRY)
CLIENT_INFLIGHT = Gauge("mcp_calc_client_inflight_computations", "Computations currently running or waiting, per client", ["client"], registry=REGISTRY)
AUTH_FAILURES = Counter(
"mcp_calc_auth_failures",
"Rejected requests: missing_credentials | invalid_token | disabled | expired | network_denied",
["reason"],
registry=REGISTRY,
)
CLIENTS = Gauge("mcp_calc_clients", "Configured API clients by status (active | disabled | expired)", ["status"], registry=REGISTRY)
def render() -> tuple[bytes, str]:
"""Metrics in Prometheus text format and the matching content type."""
return generate_latest(REGISTRY), CONTENT_TYPE_LATEST

View file

@ -0,0 +1,7 @@
"""Pure math operations.
Everything in this package is synchronous, side-effect free and returns plain
JSON-serialisable dicts. The functions run inside isolated worker processes
(see ``mcp_calculator.executor``), so they may be slow or even hang without
affecting the server.
"""

View file

@ -0,0 +1,268 @@
"""Evaluation, algebraic transformations and equation solving."""
from __future__ import annotations
from typing import Any
import sympy as sp
from mcp_calculator.ops.common import MathError, check_list, check_precision, clip, fmt, numeric, symbols_of, to_str
from mcp_calculator.ops.parsing import apply_assumptions, as_equation, parse, parse_symbol, parse_symbols
from mcp_calculator.ops.spec import Assumption, Domain, TransformOperation
# --------------------------------------------------------------------------- evaluate
def evaluate(
expression: str,
precision: int = 15,
variables: dict[str, str | float | int] | None = None,
) -> dict[str, Any]:
check_precision(precision)
expr = parse(expression)
if variables:
check_list(list(variables), "variables")
subs = {parse_symbol(name): parse(str(value)) for name, value in variables.items()}
expr = expr.subs(subs)
out: dict[str, Any] = {"exact": to_str(expr), "latex": clip(sp.latex(expr))}
approx = numeric(expr, precision)
if approx is not None:
out["numeric"] = approx
elif expr.free_symbols:
out["numeric"] = clip(str(sp.N(expr, precision)))
out["free_symbols"] = symbols_of(expr)
out["note"] = "Result still contains variables; pass values via 'variables' for a number."
if isinstance(expr, sp.Integer) and abs(expr) >= 10**precision:
out["digits"] = len(str(abs(expr)))
return out
# --------------------------------------------------------------------------- transform
def transform(
expression: str,
operation: TransformOperation = "simplify",
variable: str | None = None,
substitutions: dict[str, str | float | int] | None = None,
assume: Assumption = "none",
) -> dict[str, Any]:
expr = parse(expression, exact=operation != "nsimplify")
expr, mapping = apply_assumptions(expr, assume)
var = None
if variable:
var = parse_symbol(variable)
var = mapping.get(var, var)
match operation:
case "simplify":
result = sp.simplify(expr)
case "expand":
result = sp.expand(expr)
case "factor":
result = sp.factor(expr, gaussian=False) if var is None else sp.factor(expr, var)
case "collect":
if var is None:
raise MathError("'collect' needs 'variable'")
result = sp.collect(sp.expand(expr), var)
case "cancel":
result = sp.cancel(expr)
case "apart":
result = sp.apart(expr, var) if var is not None else sp.apart(expr)
case "together":
result = sp.together(expr)
case "trigsimp":
result = sp.trigsimp(expr)
case "expand_trig":
result = sp.expand_trig(expr)
case "powsimp":
result = sp.powsimp(expr, force=assume == "positive")
case "expand_log":
result = sp.expand_log(expr, force=assume == "positive")
case "logcombine":
result = sp.logcombine(expr, force=assume == "positive")
case "radsimp" | "rationalize_denominator":
result = sp.radsimp(expr)
case "nsimplify":
result = sp.nsimplify(expr, [sp.pi, sp.E, sp.sqrt(2), sp.sqrt(3), sp.sqrt(5)])
case "substitute":
if not substitutions:
raise MathError("'substitute' needs 'substitutions', e.g. {\"x\": \"2*t\"}")
subs = {}
for name, value in substitutions.items():
sym = parse_symbol(name)
subs[mapping.get(sym, sym)] = parse(str(value))
result = sp.simplify(expr.subs(subs))
case _:
raise MathError(f"unknown operation {operation!r}")
out = fmt(result)
out["operation"] = operation
out["equivalent_to_input"] = _equivalent(expr, result) if operation not in ("substitute", "nsimplify") else None
return {k: v for k, v in out.items() if v is not None}
def _equivalent(a: sp.Basic, b: sp.Basic) -> bool | None:
if isinstance(a, sp.Rel) or isinstance(b, sp.Rel):
return None
try:
return bool(sp.simplify(a - b) == 0) or None
except (TypeError, ValueError):
return None
# --------------------------------------------------------------------------- solve
def solve(
equations: list[str],
variables: list[str] | None = None,
domain: Domain = "complex",
numeric_only: bool = False,
initial_guess: list[float] | None = None,
precision: int = 15,
) -> dict[str, Any]:
check_precision(precision)
check_list(equations, "equations", max_items=50)
parsed = [parse(e) for e in equations]
syms = parse_symbols(variables) if variables else sorted(set().union(*(p.free_symbols for p in parsed)), key=lambda s: s.name)
if not syms:
raise MathError("no variable to solve for")
if domain != "complex":
all_syms = set(syms).union(*(p.free_symbols for p in parsed))
mapping = {s: sp.Symbol(s.name, **{domain: True}) for s in all_syms if isinstance(s, sp.Symbol)}
parsed = [p.xreplace(mapping) for p in parsed]
syms = [mapping[s] for s in syms]
# With assumptions an equation may already evaluate to True/False (x^2 = -1 over the reals).
if any(p is sp.false for p in parsed):
return {"variables": [s.name for s in syms], "domain": domain, "solutions": [], "count": 0, "note": f"No solution in the {domain} domain."}
parsed = [p for p in parsed if p is not sp.true]
if not parsed:
return {"variables": [s.name for s in syms], "domain": domain, "note": f"Every value in the {domain} domain is a solution."}
if any(isinstance(p, sp.Rel) and not isinstance(p, (sp.Equality, sp.Unequality)) for p in parsed):
return _solve_inequalities(parsed, syms)
eqs = [as_equation(p) for p in parsed]
if numeric_only:
return _solve_numeric(eqs, syms, initial_guess, precision)
if len(eqs) == 1 and len(syms) == 1:
return _solve_single(eqs[0], syms[0], domain, precision)
return _solve_system(eqs, syms, precision)
def _solve_single(eq: sp.Basic, var: sp.Symbol, domain: Domain, precision: int) -> dict[str, Any]:
sympy_domain = {"complex": sp.S.Complexes, "real": sp.S.Reals, "positive": sp.Interval.open(0, sp.oo), "integer": sp.S.Integers}[domain]
solution_set = sp.solveset(eq, var, domain=sympy_domain)
name = var.name
out: dict[str, Any] = {"variables": [name], "domain": domain}
if isinstance(solution_set, sp.FiniteSet):
solutions = sorted(solution_set, key=sp.default_sort_key)
out["solutions"] = [{name: to_str(s)} for s in solutions]
out["numeric"] = [{name: numeric(s, precision) or to_str(s)} for s in solutions]
out["latex"] = [sp.latex(s) for s in solutions]
out["count"] = len(solutions)
return out
out["solution_set"] = to_str(solution_set)
out["latex"] = sp.latex(solution_set)
if solution_set is sp.S.EmptySet:
out["solutions"] = []
out["count"] = 0
return out
# Infinite or implicit solution sets: add principal solutions as a readable fallback.
try:
principal = sp.solve(eq, var)
if principal:
out["principal_solutions"] = [{name: to_str(s)} for s in principal]
out["numeric"] = [{name: numeric(s, precision) or to_str(s)} for s in principal]
except (NotImplementedError, ValueError, TypeError):
pass
if isinstance(solution_set, sp.ConditionSet):
out["note"] = "No closed form found. Retry with numeric_only=true and an initial_guess."
else:
out["note"] = "Infinitely many solutions; 'solution_set' describes all of them (n is an integer)."
return out
def _solve_system(eqs: list[sp.Basic], syms: list[sp.Symbol], precision: int) -> dict[str, Any]:
try:
solutions = sp.solve(eqs, syms, dict=True)
except NotImplementedError as exc:
raise MathError(f"no symbolic solution method available ({exc}); retry with numeric_only=true") from None
out: dict[str, Any] = {"variables": [s.name for s in syms]}
out["solutions"] = [{str(k): to_str(v) for k, v in sol.items()} for sol in solutions]
out["numeric"] = [{str(k): numeric(v, precision) or to_str(v) for k, v in sol.items()} for sol in solutions]
out["count"] = len(solutions)
free = sorted({str(s) for sol in solutions for v in sol.values() for s in v.free_symbols} & {s.name for s in syms})
if free:
out["note"] = f"Infinitely many solutions, parametrised by: {', '.join(free)}"
elif not solutions:
out["note"] = "The system has no solution."
return out
def _solve_numeric(eqs: list[sp.Basic], syms: list[sp.Symbol], guess: list[float] | None, precision: int) -> dict[str, Any]:
exprs = [e.lhs - e.rhs for e in eqs]
names = [s.name for s in syms]
# Univariate polynomial: all complex roots at once.
if len(exprs) == 1 and len(syms) == 1 and guess is None:
poly = sp.Poly(exprs[0], syms[0]) if exprs[0].is_polynomial(syms[0]) else None
if poly is not None and poly.degree() > 0:
roots = poly.nroots(n=precision)
return {
"variables": names,
"method": "polynomial roots",
"solutions": [{names[0]: str(r)} for r in roots],
"count": len(roots),
}
if len(exprs) != len(syms):
raise MathError(f"numeric solving needs as many equations as variables ({len(exprs)} vs {len(syms)})")
start = guess or [1.0] * len(syms)
if len(start) != len(syms):
raise MathError(f"initial_guess needs {len(syms)} value(s), one per variable in order {names}")
try:
sol = sp.nsolve(exprs if len(exprs) > 1 else exprs[0], syms if len(syms) > 1 else syms[0], start if len(start) > 1 else start[0], prec=precision)
except (ValueError, ZeroDivisionError) as exc:
raise MathError(f"numeric solver did not converge from {start}: {exc}. Try another initial_guess.") from None
values = list(sol) if isinstance(sol, sp.MatrixBase) else [sol]
return {
"variables": names,
"method": "Newton iteration (nsolve)",
"initial_guess": start,
"solutions": [{n: str(sp.N(v, precision)) for n, v in zip(names, values, strict=True)}],
"count": 1,
"note": "Numeric methods return one root near the initial guess; other roots may exist.",
}
def _solve_inequalities(parsed: list[sp.Basic], syms: list[sp.Symbol]) -> dict[str, Any]:
if len(syms) != 1:
raise MathError("inequalities can only be solved for a single variable")
var = syms[0]
if not var.is_real:
var_real = sp.Symbol(var.name, real=True)
parsed = [p.xreplace({var: var_real}) for p in parsed]
var = var_real
result = sp.reduce_inequalities([as_equation(p) for p in parsed], [var])
if result is sp.true:
as_set = sp.S.Reals
elif result is sp.false:
as_set = sp.S.EmptySet
else:
as_set = result.as_set()
return {
"variables": [var.name],
"domain": "real",
"solution": to_str(result),
"solution_set": to_str(as_set),
"latex": sp.latex(as_set),
}

View file

@ -0,0 +1,338 @@
"""Calculus: derivatives, integrals, limits, series, sums, gradients, function analysis, ODEs."""
from __future__ import annotations
import re
from typing import Any
import sympy as sp
from sympy.calculus.util import continuous_domain
from mcp_calculator.ops.common import MathError, check_precision, fmt, numeric, to_str
from mcp_calculator.ops.parsing import apply_assumptions, parse, parse_symbol, parse_symbols, validate
from mcp_calculator.ops.spec import Assumption, CalculusOperation, Direction
def calculus(
operation: CalculusOperation,
expression: str,
variable: str = "x",
order: int = 1,
lower: str | None = None,
upper: str | None = None,
point: str | None = None,
direction: Direction = "+-",
terms: int = 6,
assume: Assumption = "none",
precision: int = 15,
) -> dict[str, Any]:
check_precision(precision)
if operation == "jacobian":
exprs = [parse(e.strip()) for e in _split_top_level(expression)]
else:
exprs = [parse(expression)]
combined = sp.Tuple(*exprs)
combined, mapping = apply_assumptions(combined, assume)
exprs = list(combined)
variables = [mapping.get(s, s) for s in parse_symbols(variable)]
if not variables:
raise MathError("'variable' is required")
expr, var = exprs[0], variables[0]
match operation:
case "derivative":
if not 1 <= order <= 50:
raise MathError("order must be between 1 and 50")
# Several variables mean a mixed partial derivative d/dx d/dy ...
spec = [(v, order) for v in variables] if len(variables) > 1 else [(var, order)]
result = sp.simplify(sp.diff(expr, *spec))
out = fmt(result, precision)
case "integral":
out = _integral(expr, var, lower, upper, precision)
case "limit":
if point is None:
raise MathError("'limit' needs 'point' (e.g. \"0\" or \"oo\")")
result = sp.limit(expr, var, parse(point), dir=direction)
out = fmt(result, precision)
out["direction"] = direction
case "series":
if not 1 <= terms <= 50:
raise MathError("terms must be between 1 and 50")
x0 = parse(point) if point is not None else sp.Integer(0)
series = sp.series(expr, var, x0, terms)
out = fmt(series, precision)
out["polynomial"] = to_str(series.removeO())
out["point"] = str(x0)
case "sum" | "product":
if lower is None or upper is None:
raise MathError(f"'{operation}' needs 'lower' and 'upper' bounds (upper may be \"oo\")")
bounds = (var, parse(lower), parse(upper))
unevaluated = sp.Sum(expr, bounds) if operation == "sum" else sp.Product(expr, bounds)
result = unevaluated.doit()
out = fmt(sp.simplify(result) if not result.has(sp.Sum, sp.Product) else result, precision)
if result.has(sp.Sum, sp.Product):
out["numeric"] = str(sp.N(unevaluated, precision))
out["method"] = "numeric (no closed form found)"
if operation == "sum" and parse(upper) is sp.oo:
try:
out["converges"] = bool(sp.Sum(expr, bounds).is_convergent())
except (NotImplementedError, ValueError, TypeError):
pass
case "gradient":
grad = [sp.simplify(sp.diff(expr, v)) for v in variables]
out = {"result": [to_str(g) for g in grad], "latex": sp.latex(sp.Matrix(grad)), "variables": [v.name for v in variables]}
case "hessian":
hess = sp.simplify(sp.hessian(expr, variables))
out = {"result": [[to_str(c) for c in row] for row in hess.tolist()], "latex": sp.latex(hess), "variables": [v.name for v in variables]}
case "jacobian":
jac = sp.simplify(sp.Matrix(exprs).jacobian(variables))
out = {"result": [[to_str(c) for c in row] for row in jac.tolist()], "latex": sp.latex(jac), "variables": [v.name for v in variables]}
case _:
raise MathError(f"unknown operation {operation!r}")
out["operation"] = operation
return out
def _integral(expr: sp.Expr, var: sp.Symbol, lower: str | None, upper: str | None, precision: int) -> dict[str, Any]:
if (lower is None) != (upper is None):
raise MathError("give both 'lower' and 'upper' for a definite integral, or neither")
if lower is None:
result = sp.integrate(expr, var)
if result.has(sp.Integral):
return {"result": to_str(result), "latex": sp.latex(result), "note": "No closed-form antiderivative found."}
out = fmt(sp.simplify(result), precision)
out["note"] = "Indefinite integral; add an integration constant C."
return out
a, b = parse(lower), parse(upper)
result = sp.integrate(expr, (var, a, b))
if not result.has(sp.Integral):
out = fmt(sp.simplify(result), precision)
out["method"] = "symbolic"
return out
value = sp.N(sp.Integral(expr, (var, a, b)), precision)
return {"result": str(value), "numeric": str(value), "method": "numeric quadrature (no closed form found)"}
def _split_top_level(text: str) -> list[str]:
"""Split 'f, g, h' at top-level commas (commas inside function calls are kept)."""
parts, depth, current = [], 0, []
for ch in text:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if ch == "," and depth == 0:
parts.append("".join(current))
current = []
else:
current.append(ch)
parts.append("".join(current))
return [p for p in parts if p.strip()]
# --------------------------------------------------------------------------- function analysis
def analyze_function(expression: str, variable: str = "x", precision: int = 10) -> dict[str, Any]:
"""Full curve sketching ("Kurvendiskussion") of a real function f(x)."""
check_precision(precision)
x_plain = parse_symbol(variable)
x = sp.Symbol(x_plain.name, real=True)
f = parse(expression).xreplace({x_plain: x})
if f.free_symbols - {x}:
others = ", ".join(sorted(s.name for s in f.free_symbols - {x}))
raise MathError(f"function must depend only on {x.name}; found parameter(s): {others}")
out: dict[str, Any] = {"function": to_str(f), "latex": sp.latex(f)}
d1 = sp.simplify(sp.diff(f, x))
d2 = sp.simplify(sp.diff(d1, x))
d3 = sp.simplify(sp.diff(d2, x))
out["derivatives"] = {"f'": to_str(d1), "f''": to_str(d2), "f'''": to_str(d3)}
def attempt(key: str, fn) -> None:
try:
out[key] = fn()
except (NotImplementedError, ValueError, TypeError, AttributeError):
out[key] = "not determinable"
def points(values: list[sp.Expr]) -> list[dict[str, Any]]:
return [{"x": to_str(v), "x_numeric": numeric(v, precision) or to_str(v)} for v in values]
domain = continuous_domain(f, x, sp.S.Reals)
out["domain"] = to_str(domain)
def real_roots(expr: sp.Expr) -> sp.Set:
return sp.solveset(expr, x, domain)
def zeros():
s = real_roots(f)
return points(sorted(s, key=sp.default_sort_key)) if isinstance(s, sp.FiniteSet) else to_str(s)
attempt("zeros", zeros)
def y_intercept():
if not domain.contains(0):
return None
return fmt(f.subs(x, 0), precision)["result"]
attempt("y_intercept", y_intercept)
def extrema():
candidates = real_roots(d1)
if not isinstance(candidates, sp.FiniteSet):
return to_str(candidates)
found = []
for c in sorted(candidates, key=sp.default_sort_key):
curvature = sp.simplify(d2.subs(x, c))
if curvature.is_positive:
kind = "minimum"
elif curvature.is_negative:
kind = "maximum"
else:
kind = _classify_by_sign_change(d1, x, c)
y = sp.simplify(f.subs(x, c))
found.append({"x": to_str(c), "y": to_str(y), "type": kind, "x_numeric": numeric(c, precision) or to_str(c), "y_numeric": numeric(y, precision) or to_str(y)})
return found
attempt("extrema", extrema)
def inflection_points():
candidates = real_roots(d2)
if not isinstance(candidates, sp.FiniteSet):
return to_str(candidates)
found = []
for c in sorted(candidates, key=sp.default_sort_key):
if sp.simplify(d3.subs(x, c)) != 0 or _classify_by_sign_change(d2, x, c) == "sign change":
y = sp.simplify(f.subs(x, c))
found.append({"x": to_str(c), "y": to_str(y), "x_numeric": numeric(c, precision) or to_str(c), "y_numeric": numeric(y, precision) or to_str(y)})
return found
attempt("inflection_points", inflection_points)
def symmetry():
if sp.simplify(f.subs(x, -x) - f) == 0:
return "even (symmetric to the y-axis)"
if sp.simplify(f.subs(x, -x) + f) == 0:
return "odd (point-symmetric to the origin)"
return "none"
attempt("symmetry", symmetry)
attempt("limit_at_minus_infinity", lambda: to_str(sp.limit(f, x, -sp.oo)))
attempt("limit_at_plus_infinity", lambda: to_str(sp.limit(f, x, sp.oo)))
def vertical_asymptotes():
singular = sp.S.Reals - domain
if not isinstance(singular, sp.FiniteSet):
return [] if singular is sp.S.EmptySet else to_str(singular)
return [to_str(p) for p in sorted(singular, key=sp.default_sort_key) if sp.limit(f, x, p, dir="+").is_infinite or sp.limit(f, x, p, dir="-").is_infinite]
attempt("vertical_asymptotes", vertical_asymptotes)
def monotonicity():
increasing = sp.solveset(d1 > 0, x, domain)
decreasing = sp.solveset(d1 < 0, x, domain)
return {"increasing": to_str(increasing), "decreasing": to_str(decreasing)}
attempt("monotonicity", monotonicity)
def curvature():
return {"convex": to_str(sp.solveset(d2 > 0, x, domain)), "concave": to_str(sp.solveset(d2 < 0, x, domain))}
attempt("curvature", curvature)
return out
def _classify_by_sign_change(derivative: sp.Expr, x: sp.Symbol, c: sp.Expr) -> str:
eps = sp.Rational(1, 10**6)
left, right = derivative.subs(x, c - eps), derivative.subs(x, c + eps)
try:
left_sign, right_sign = sp.sign(sp.N(left)), sp.sign(sp.N(right))
except TypeError:
return "undetermined"
if left_sign < 0 < right_sign:
return "minimum"
if left_sign > 0 > right_sign:
return "maximum"
if left_sign == right_sign and left_sign != 0:
return "saddle point (no sign change)"
return "sign change" if left_sign != right_sign else "undetermined"
# --------------------------------------------------------------------------- ODEs
# Names may directly follow a digit ("2y'"), so a letter/underscore lookbehind is used instead of \b.
_PRIMES = re.compile(r"(?<![^\W\d])([^\W\d]\w*)('+)")
def solve_ode(
equation: str,
function: str = "y",
variable: str = "x",
initial_conditions: dict[str, str | float | int] | None = None,
) -> dict[str, Any]:
"""Solve an ordinary differential equation like y'' + y = sin(x)."""
fname = parse_symbol(function).name
x = parse_symbol(variable)
y = sp.Function(fname)
text = _expand_ode_notation(equation, fname, x.name)
eq = parse(text, local={fname: y, "Derivative": sp.Derivative})
if not isinstance(eq, sp.Equality):
eq = sp.Eq(eq, 0)
if not eq.has(sp.Derivative):
raise MathError(f"no derivative of {fname}({x.name}) found; write e.g. {fname}' or {fname}''")
ics = None
if initial_conditions:
ics = {}
for key, value in initial_conditions.items():
ics.update(_parse_initial_condition(key, value, fname, y, x))
try:
solution = sp.dsolve(eq, y(x), ics=ics)
except NotImplementedError as exc:
raise MathError(f"no solution method available for this ODE ({exc})") from None
except ValueError as exc:
raise MathError(f"could not solve: {exc}") from None
solutions = solution if isinstance(solution, list) else [solution]
try:
classification = sp.classify_ode(eq, y(x))
except (NotImplementedError, ValueError):
classification = ()
out: dict[str, Any] = {
"equation": to_str(eq),
"solutions": [to_str(s.rhs if isinstance(s, sp.Equality) else s) for s in solutions],
"latex": [sp.latex(s) for s in solutions],
"method": classification[0] if classification else None,
}
if ics is None and any(s.has(sp.Symbol("C1")) for s in solutions):
out["note"] = "General solution; C1, C2, ... are integration constants. Pass initial_conditions to fix them."
return {k: v for k, v in out.items() if v is not None}
def _expand_ode_notation(text: str, fname: str, var: str) -> str:
"""y'' -> Derivative(y(x), x, 2) and a bare y -> y(x)."""
validate(text.replace("'", "")) # validate everything except the prime marks
def prime(match: re.Match[str]) -> str:
if match.group(1) != fname:
raise MathError(f"prime notation is only supported for the unknown function {fname!r}")
return f"Derivative({fname}({var}), {var}, {len(match.group(2))})"
text = _PRIMES.sub(prime, text)
if "'" in text:
raise MathError("unexpected quote character")
# Bare function name (not followed by '(') means y(x).
return re.sub(rf"(?<![^\W\d]){re.escape(fname)}(?!\w)(?!\s*\()", f"{fname}({var})", text)
def _parse_initial_condition(key: str, value: Any, fname: str, y: sp.Function, x: sp.Symbol) -> dict[sp.Basic, sp.Basic]:
match = re.fullmatch(rf"\s*{re.escape(fname)}('*)\s*\((.+)\)\s*", key)
if not match:
raise MathError(f"initial condition keys look like \"{fname}(0)\" or \"{fname}'(0)\", got {key!r}")
order, at = len(match.group(1)), parse(match.group(2))
lhs = y(at) if order == 0 else y(x).diff(x, order).subs(x, at)
return {lhs: parse(str(value))}

View file

@ -0,0 +1,69 @@
"""Shared helpers for the math operations: limits checks and result formatting."""
from __future__ import annotations
from typing import Any
import sympy as sp
from mcp_calculator.ops.context import MathError, limits
__all__ = ["MathError", "check_list", "check_precision", "clip", "fmt", "limits", "numeric", "symbols_of", "to_str"]
def clip(text: str) -> str:
"""Truncate very long output so a single result cannot flood the context window."""
max_len = limits().max_output_length
if len(text) <= max_len:
return text
return f"{text[:max_len]}... [truncated, {len(text)} characters total]"
def check_precision(precision: int) -> int:
if not 1 <= precision <= limits().max_precision:
raise MathError(f"precision must be between 1 and {limits().max_precision}")
return precision
def check_list(items: list[Any], name: str, *, min_items: int = 1, max_items: int | None = None) -> None:
max_items = max_items or limits().max_list_items
if len(items) < min_items:
raise MathError(f"'{name}' needs at least {min_items} item(s)")
if len(items) > max_items:
raise MathError(f"'{name}' has {len(items)} items; the limit is {max_items}")
def numeric(expr: sp.Basic, precision: int = 15) -> str | None:
"""Decimal approximation, or None if the expression is not a plain number."""
try:
if not getattr(expr, "is_number", False) or expr.has(sp.nan, sp.zoo):
return None
if expr.is_Integer and len(str(expr)) <= precision:
return str(expr)
return clip(str(sp.N(expr, precision)))
except (TypeError, ValueError, AttributeError):
return None
def fmt(expr: Any, precision: int = 15) -> dict[str, Any]:
"""Standard representation of a single SymPy result.
``result`` uses the same syntax the tools accept as input, so it can be fed
straight into the next tool call.
"""
expr = sp.sympify(expr)
out: dict[str, Any] = {"result": clip(str(expr)), "latex": clip(sp.latex(expr))}
approx = numeric(expr, precision)
if approx is not None and approx != out["result"]:
out["numeric"] = approx
if isinstance(expr, sp.Integer) and abs(expr) >= 10**precision:
out["digits"] = len(str(abs(expr)))
return out
def to_str(expr: Any) -> str:
return clip(str(expr))
def symbols_of(expr: sp.Basic) -> list[str]:
return sorted(str(s) for s in expr.free_symbols)

View file

@ -0,0 +1,20 @@
"""Error type and active limits. Import-light: used by the server process and the executor."""
from __future__ import annotations
from mcp_calculator.config import Limits
_limits = Limits()
class MathError(ValueError):
"""An anticipated failure whose message is shown to the model verbatim."""
def set_limits(limits: Limits) -> None:
global _limits
_limits = limits
def limits() -> Limits:
return _limits

View file

@ -0,0 +1,248 @@
"""Linear algebra on exact (SymPy) or floating point (NumPy) matrices."""
from __future__ import annotations
from typing import Any, Union
import numpy as np
import sympy as sp
from mcp_calculator.ops.common import MathError, check_precision, clip, limits, numeric, to_str
from mcp_calculator.ops.parsing import parse
from mcp_calculator.ops.spec import MatrixOperation
Entry = Union[str, float, int]
MatrixInput = Union[list[list[Entry]], list[Entry]]
_NEEDS_OTHER = {"multiply", "add", "subtract", "solve", "dot", "cross"}
_NUMERIC_ONLY = {"svd", "condition_number"}
def matrix(
operation: MatrixOperation,
matrix: MatrixInput,
other: MatrixInput | None = None,
exponent: int | None = None,
numeric_mode: bool = False,
precision: int = 15,
) -> dict[str, Any]:
check_precision(precision)
a = _to_matrix(matrix, "matrix")
b = _to_matrix(other, "other") if other is not None else None
if operation in _NEEDS_OTHER and b is None:
raise MathError(f"'{operation}' needs a second matrix/vector in 'other'")
if numeric_mode or operation in _NUMERIC_ONLY:
return {"operation": operation, "mode": "numeric (float64)", **_numeric(operation, a, b, exponent)}
return {"operation": operation, "mode": "exact", **_exact(operation, a, b, exponent, precision)}
def _to_matrix(data: MatrixInput | None, name: str) -> sp.Matrix:
if not isinstance(data, list) or not data:
raise MathError(f"'{name}' must be a non-empty list of rows, e.g. [[1, 2], [3, 4]]")
rows = data if isinstance(data[0], list) else [[v] for v in data] # flat list = column vector
size = limits().max_matrix_size
if len(rows) > size or any(len(r) > size for r in rows):
raise MathError(f"'{name}' is larger than {size}x{size}")
if len({len(r) for r in rows}) != 1:
raise MathError(f"all rows of '{name}' must have the same length")
return sp.Matrix([[parse(str(v)) for v in row] for row in rows])
def _mat_out(m: sp.Matrix, precision: int) -> dict[str, Any]:
out: dict[str, Any] = {
"result": [[to_str(v) for v in row] for row in m.tolist()],
"shape": list(m.shape),
"latex": clip(sp.latex(m)),
}
if all(getattr(v, "is_number", False) for v in m) and any(not v.is_Integer for v in m):
out["numeric"] = [[numeric(v, precision) or to_str(v) for v in row] for row in m.tolist()]
return out
def _scalar_out(value: sp.Expr, precision: int) -> dict[str, Any]:
out: dict[str, Any] = {"result": to_str(value), "latex": sp.latex(value)}
approx = numeric(value, precision)
if approx is not None and approx != out["result"]:
out["numeric"] = approx
return out
def _require_square(a: sp.Matrix, operation: str) -> None:
if not a.is_square:
raise MathError(f"'{operation}' needs a square matrix, got shape {a.shape[0]}x{a.shape[1]}")
def _exact(operation: str, a: sp.Matrix, b: sp.Matrix | None, exponent: int | None, precision: int) -> dict[str, Any]:
match operation:
case "determinant":
_require_square(a, operation)
return _scalar_out(sp.simplify(a.det()), precision)
case "inverse":
_require_square(a, operation)
if a.det() == 0:
raise MathError("matrix is singular (determinant 0) and has no inverse; try 'pseudo_inverse'")
return _mat_out(sp.simplify(a.inv()), precision)
case "transpose":
return _mat_out(a.T, precision)
case "rank":
return {"result": a.rank()}
case "trace":
_require_square(a, operation)
return _scalar_out(a.trace(), precision)
case "rref":
reduced, pivots = a.rref()
return {**_mat_out(reduced, precision), "pivot_columns": list(pivots)}
case "eigenvalues":
_require_square(a, operation)
values = a.eigenvals()
return {
"result": [{"value": to_str(v), "numeric": numeric(v, precision), "multiplicity": m} for v, m in values.items()],
}
case "eigenvectors":
_require_square(a, operation)
return {
"result": [
{
"value": to_str(v),
"numeric": numeric(v, precision),
"multiplicity": m,
"vectors": [[to_str(c) for c in vec] for vec in vecs],
}
for v, m, vecs in a.eigenvects()
]
}
case "characteristic_polynomial":
_require_square(a, operation)
lam = sp.Symbol("lambda")
poly = a.charpoly(lam).as_expr()
return {"result": to_str(poly), "latex": sp.latex(poly), "variable": "lambda"}
case "diagonalize":
_require_square(a, operation)
if not a.is_diagonalizable():
raise MathError("matrix is not diagonalizable")
p, d = a.diagonalize()
return {"P": _mat_out(p, precision)["result"], "D": _mat_out(d, precision)["result"], "note": "matrix = P * D * P^-1"}
case "nullspace":
return {"result": [[to_str(c) for c in vec] for vec in a.nullspace()]}
case "columnspace":
return {"result": [[to_str(c) for c in vec] for vec in a.columnspace()]}
case "multiply":
assert b is not None
if a.shape[1] != b.shape[0]:
raise MathError(f"shapes {a.shape} and {b.shape} are not compatible for multiplication")
return _mat_out(a * b, precision)
case "add" | "subtract":
assert b is not None
if a.shape != b.shape:
raise MathError(f"shapes {a.shape} and {b.shape} differ")
return _mat_out(a + b if operation == "add" else a - b, precision)
case "power":
_require_square(a, operation)
if exponent is None:
raise MathError("'power' needs 'exponent'")
if abs(exponent) > 1000:
raise MathError("|exponent| must be <= 1000")
return _mat_out(sp.simplify(a**exponent), precision)
case "solve":
assert b is not None
if b.shape[0] != a.shape[0]:
raise MathError(f"'other' must have {a.shape[0]} rows (right-hand side b of A*x = b)")
try:
solution, params = a.gauss_jordan_solve(b)
except ValueError:
return {"result": None, "note": "The linear system has no solution (inconsistent)."}
out = _mat_out(solution, precision)
if params.shape[0]:
out["note"] = f"Infinitely many solutions; free parameters: {', '.join(str(p) for p in params)}"
return out
case "dot":
assert b is not None
if len(a) != len(b):
raise MathError("vectors must have the same length")
return _scalar_out(sp.Matrix(list(a)).dot(sp.Matrix(list(b))), precision)
case "cross":
assert b is not None
if len(a) != 3 or len(b) != 3:
raise MathError("cross product needs two 3-vectors")
return _mat_out(sp.Matrix(list(a)).cross(sp.Matrix(list(b))), precision)
case "norm":
value = a.norm() if 1 in a.shape else a.norm("fro")
return {**_scalar_out(sp.simplify(value), precision), "kind": "euclidean" if 1 in a.shape else "frobenius"}
case "pseudo_inverse":
return _mat_out(sp.simplify(a.pinv()), precision)
raise MathError(f"unknown operation {operation!r}")
def _to_float(m: sp.Matrix) -> np.ndarray:
try:
return np.array(m.evalf(), dtype=complex if any(not v.is_real for v in m) else float)
except TypeError:
raise MathError("numeric mode needs numeric entries (no variables)") from None
def _np_list(x: np.ndarray) -> Any:
if np.iscomplexobj(x):
if np.allclose(x.imag, 0):
x = x.real
else:
return [str(v) for v in np.ravel(x)] if x.ndim == 1 else [[str(v) for v in row] for row in x]
return np.round(x, 12).tolist()
def _numeric(operation: str, a_sym: sp.Matrix, b_sym: sp.Matrix | None, exponent: int | None) -> dict[str, Any]:
a = _to_float(a_sym)
b = _to_float(b_sym) if b_sym is not None else None
square = a.shape[0] == a.shape[1]
try:
match operation:
case "determinant" if square:
return {"result": float(np.linalg.det(a))}
case "inverse" if square:
return {"result": _np_list(np.linalg.inv(a))}
case "transpose":
return {"result": _np_list(a.T)}
case "rank":
return {"result": int(np.linalg.matrix_rank(a))}
case "trace" if square:
return {"result": float(np.trace(a))}
case "eigenvalues" if square:
return {"result": _np_list(np.linalg.eigvals(a))}
case "eigenvectors" if square:
values, vectors = np.linalg.eig(a)
return {"eigenvalues": _np_list(values), "eigenvectors_as_columns": _np_list(vectors)}
case "multiply":
return {"result": _np_list(a @ b)}
case "add":
return {"result": _np_list(a + b)}
case "subtract":
return {"result": _np_list(a - b)}
case "power" if square:
if exponent is None:
raise MathError("'power' needs 'exponent'")
return {"result": _np_list(np.linalg.matrix_power(a, exponent))}
case "solve":
if square:
return {"result": _np_list(np.linalg.solve(a, b))}
x, residuals, rank, _ = np.linalg.lstsq(a, b, rcond=None)
return {"result": _np_list(x), "method": "least squares", "rank": int(rank), "residuals": _np_list(residuals)}
case "dot":
return {"result": float(np.dot(a.ravel(), b.ravel()))}
case "cross":
return {"result": _np_list(np.cross(a.ravel(), b.ravel()))}
case "norm":
return {"result": float(np.linalg.norm(a))}
case "svd":
u, s, vt = np.linalg.svd(a)
return {"U": _np_list(u), "singular_values": _np_list(s), "Vt": _np_list(vt)}
case "pseudo_inverse":
return {"result": _np_list(np.linalg.pinv(a))}
case "condition_number":
return {"result": float(np.linalg.cond(a))}
case "determinant" | "inverse" | "trace" | "eigenvalues" | "eigenvectors" | "power":
raise MathError(f"'{operation}' needs a square matrix")
except np.linalg.LinAlgError as exc:
raise MathError(f"numeric linear algebra failed: {exc}") from None
except ValueError as exc:
raise MathError(f"incompatible shapes: {exc}") from None
raise MathError(f"'{operation}' is only available in exact mode (numeric_mode=false)")

View file

@ -0,0 +1,218 @@
"""Exact integer arithmetic: primes, divisibility, modular arithmetic, combinatorics, bases."""
from __future__ import annotations
from typing import Any, Union
import sympy as sp
from sympy.ntheory.modular import crt
from mcp_calculator.ops.common import MathError, check_list, clip, to_str
from mcp_calculator.ops.parsing import parse
from mcp_calculator.ops.spec import NumberTheoryOperation
IntLike = Union[int, str]
# operation -> (min values, max values, value names for error messages)
_ARITY: dict[str, tuple[int, int, str]] = {
"gcd": (2, 10_000, "[a, b, ...]"),
"lcm": (2, 10_000, "[a, b, ...]"),
"factorize": (1, 1, "[n]"),
"is_prime": (1, 1, "[n]"),
"next_prime": (1, 1, "[n]"),
"previous_prime": (1, 1, "[n]"),
"nth_prime": (1, 1, "[k]"),
"primes_in_range": (2, 2, "[low, high]"),
"divisors": (1, 1, "[n]"),
"totient": (1, 1, "[n]"),
"mod": (2, 2, "[a, m]"),
"mod_pow": (3, 3, "[base, exponent, modulus]"),
"mod_inverse": (2, 2, "[a, m]"),
"crt": (2, 10_000, "[r1, r2, ...] plus 'moduli'"),
"factorial": (1, 1, "[n]"),
"binomial": (2, 2, "[n, k]"),
"permutations": (2, 2, "[n, k]"),
"fibonacci": (1, 1, "[n]"),
"continued_fraction": (1, 1, "[x] (rational or expression)"),
"base_convert": (1, 1, "[value] plus 'from_base' / 'to_base'"),
}
_MAX_FACTORIAL = 20_000
_MAX_PRIMES = 10_000
_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
def number_theory(
operation: NumberTheoryOperation,
values: list[IntLike],
moduli: list[IntLike] | None = None,
from_base: int = 10,
to_base: int = 10,
) -> dict[str, Any]:
if operation not in _ARITY:
raise MathError(f"unknown operation {operation!r}")
lo, hi, names = _ARITY[operation]
check_list(values, "values", min_items=lo, max_items=hi)
if len(values) > hi or len(values) < lo:
raise MathError(f"'{operation}' expects values = {names}")
if operation == "base_convert":
return _base_convert(str(values[0]), from_base, to_base)
if operation == "continued_fraction":
x = parse(str(values[0]))
if not x.is_real:
raise MathError("continued fractions need a real number")
max_terms = 50 if x.is_Rational else 20
terms = _continued_fraction(x, max_terms)
out: dict[str, Any] = {"result": terms}
if len(terms) == max_terms:
out["note"] = f"first {max_terms} terms"
return out
n = [_int(v) for v in values]
match operation:
case "gcd":
result: Any = sp.gcd_list(n)
case "lcm":
result = sp.lcm_list(n)
case "factorize":
_check_magnitude(n[0], 10**60, "factorize")
factors = sp.factorint(n[0])
pretty = " * ".join(f"{p}^{e}" if e > 1 else str(p) for p, e in factors.items()) or str(n[0])
return {"result": {str(p): e for p, e in factors.items()}, "product_form": pretty}
case "is_prime":
result = bool(sp.isprime(n[0]))
case "next_prime":
result = sp.nextprime(n[0])
case "previous_prime":
if n[0] <= 2:
raise MathError("there is no prime below 2")
result = sp.prevprime(n[0])
case "nth_prime":
if not 1 <= n[0] <= 10**7:
raise MathError("k must be between 1 and 10^7")
result = sp.prime(n[0])
case "primes_in_range":
low, high = n
primes = list(sp.primerange(low, high + 1)) if high - low <= 10**7 else None
if primes is None:
raise MathError("range too large (max width 10^7)")
out = {"result": primes[:_MAX_PRIMES], "count": len(primes)}
if len(primes) > _MAX_PRIMES:
out["note"] = f"only the first {_MAX_PRIMES} are listed"
return out
case "divisors":
_check_magnitude(n[0], 10**40, "divisors")
divs = sp.divisors(n[0])
return {"result": divs[:_MAX_PRIMES], "count": len(divs), "sum": sum(divs)}
case "totient":
_check_magnitude(n[0], 10**60, "totient")
result = sp.totient(n[0])
case "mod":
if n[1] == 0:
raise MathError("modulus must not be 0")
result = n[0] % n[1]
case "mod_pow":
if n[2] == 0:
raise MathError("modulus must not be 0")
try:
result = pow(n[0], n[1], n[2])
except ValueError:
raise MathError("a negative exponent needs base and modulus to be coprime") from None
case "mod_inverse":
try:
result = sp.mod_inverse(n[0], n[1])
except ValueError:
raise MathError(f"{n[0]} has no inverse modulo {n[1]} (not coprime)") from None
case "crt":
if not moduli or len(moduli) != len(n):
raise MathError("'crt' needs 'moduli' with one modulus per residue in 'values'")
m = [_int(v) for v in moduli]
solution = crt(m, n)
if solution is None:
return {"result": None, "note": "no solution: the congruences are inconsistent"}
return {"result": int(solution[0]), "modulus": int(solution[1]), "meaning": f"x ≡ {solution[0]} (mod {solution[1]})"}
case "factorial":
if not 0 <= n[0] <= _MAX_FACTORIAL:
raise MathError(f"n must be between 0 and {_MAX_FACTORIAL}")
result = sp.factorial(n[0])
case "binomial":
result = sp.binomial(n[0], n[1])
case "permutations":
if n[1] > n[0] or n[1] < 0:
result = 0
else:
result = sp.ff(n[0], n[1])
case "fibonacci":
if not 0 <= n[0] <= 100_000:
raise MathError("n must be between 0 and 100000")
result = sp.fibonacci(n[0])
case _:
raise MathError(f"unknown operation {operation!r}")
return _int_out(result)
def _int_out(result: Any) -> dict[str, Any]:
if isinstance(result, bool):
return {"result": result}
value = int(result)
text = str(value)
out: dict[str, Any] = {"result": clip(text) if len(text) > 15 else value}
if len(text) > 15:
out["digits"] = len(text.lstrip("-"))
out["note"] = "integer given as string to keep every digit"
return out
def _int(value: IntLike) -> int:
if isinstance(value, bool):
raise MathError("booleans are not integers here")
if isinstance(value, int):
return value
if isinstance(value, float):
if not value.is_integer():
raise MathError(f"{value} is not an integer")
return int(value)
parsed = parse(str(value))
if not parsed.is_Integer:
raise MathError(f"{value!r} is not an integer")
return int(parsed)
def _check_magnitude(n: int, bound: int, operation: str) -> None:
if abs(n) > bound:
raise MathError(f"'{operation}' is limited to |n| <= 10^{len(str(bound)) - 1}")
def _continued_fraction(x: sp.Expr, max_terms: int) -> list[int]:
terms = []
for term in sp.continued_fraction_iterator(x):
terms.append(int(term))
if len(terms) >= max_terms:
break
return terms
def _base_convert(value: str, from_base: int, to_base: int) -> dict[str, Any]:
if not (2 <= from_base <= 36 and 2 <= to_base <= 36):
raise MathError("bases must be between 2 and 36")
text = value.strip().lower().replace("_", "")
for prefix, base in (("0x", 16), ("0b", 2), ("0o", 8)):
if text.lstrip("-").startswith(prefix) and from_base == base:
text = text.replace(prefix, "", 1)
if len(text) > 1000:
raise MathError("value too long")
try:
n = int(text, from_base)
except ValueError:
raise MathError(f"{value!r} is not a valid base-{from_base} number") from None
if n == 0:
digits = "0"
else:
digits, m = "", abs(n)
while m:
m, r = divmod(m, to_base)
digits = _DIGITS[r] + digits
digits = ("-" if n < 0 else "") + digits
return {"result": digits, "decimal": to_str(n), "from_base": from_base, "to_base": to_base}

View file

@ -0,0 +1,241 @@
"""Safe parsing of math expressions.
SymPy's ``parse_expr`` ultimately calls ``eval``. It is made safe here by
1. validating the raw text (character whitelist, no ``__``, no attribute access,
no string literals, no Python keywords),
2. evaluating with a namespace that contains *only* whitelisted SymPy objects and
an empty ``__builtins__``, and
3. running every computation in a sandboxed worker process with a hard timeout
(see ``mcp_calculator.executor``).
Accepted syntax (see also ``spec.SYNTAX_HELP``):
``2x`` (implicit multiplication), ``x^2`` or ``x**2``, ``5!``, ``e``, ``pi``, ``I``,
``oo``, ``sin(30*deg)``, ``ln(x)``, ``log(x, 2)``, equations ``x^2 = 4``,
inequalities ``x^2 < 4``. Decimal numbers are converted to exact fractions
(``0.1 + 0.2`` is exactly ``3/10``).
"""
from __future__ import annotations
import keyword
import re
from collections.abc import Iterable
import sympy as sp
from sympy.parsing.sympy_parser import (
convert_xor,
implicit_multiplication,
parse_expr,
rationalize,
standard_transformations,
)
from mcp_calculator.ops.common import MathError, limits
from mcp_calculator.ops.spec import Assumption
_TRANSFORMATIONS = standard_transformations + (implicit_multiplication, convert_xor, rationalize)
_TRANSFORMATIONS_FLOAT = standard_transformations + (implicit_multiplication, convert_xor)
_UNICODE = {
"π": "pi",
"×": "*",
"·": "*",
"⋅": "*",
"÷": "/",
"−": "-",
"–": "-",
"²": "**2",
"³": "**3",
"∞": "oo",
"≤": "<=",
"≥": ">=",
"≠": "!=",
"\u00a0": " ", # non-breaking space
}
_SQRT = re.compile(r"√\s*(\d+(?:\.\d+)?|[^\W\d]\w*)")
_ALLOWED_CHARS = re.compile(r"^[\w\s.,+\-*/^()<>=!%]*$")
_ATTRIBUTE_ACCESS = re.compile(r"\.\s*[^\W\d]")
_IDENTIFIER = re.compile(r"[^\W\d]\w*")
_ALLOWED_KEYWORDS = {"True", "False"}
_MAX_IDENTIFIER_LENGTH = 40
def _log10(x: sp.Expr) -> sp.Expr:
return sp.log(x, 10)
def _log2(x: sp.Expr) -> sp.Expr:
return sp.log(x, 2)
def _build_namespace() -> dict[str, object]:
names = """
sqrt cbrt root real_root exp log
sin cos tan cot sec csc asin acos atan atan2 acot asec acsc
sinh cosh tanh coth sech csch asinh acosh atanh acoth
Abs sign floor ceiling frac
factorial factorial2 binomial gamma beta erf erfc erfinv zeta
fibonacci harmonic LambertW
Min Max Mod gcd lcm re im conjugate arg
diff Derivative Integral Sum Product Piecewise Eq Ne Lt Le Gt Ge
Interval Union FiniteSet CRootOf RootOf Heaviside DiracDelta
E I pi oo zoo nan EulerGamma GoldenRatio
Symbol Function Integer Float Rational Lambda
""".split()
ns: dict[str, object] = {name: getattr(sp, name) for name in names}
ns.update(
{
"e": sp.E,
"inf": sp.oo,
"infinity": sp.oo,
"deg": sp.pi / 180,
"ln": sp.log,
"log10": _log10,
"log2": _log2,
"abs": sp.Abs,
"ceil": sp.ceiling,
"min": sp.Min,
"max": sp.Max,
"mod": sp.Mod,
"arcsin": sp.asin,
"arccos": sp.acos,
"arctan": sp.atan,
"arsinh": sp.asinh,
"arcosh": sp.acosh,
"artanh": sp.atanh,
"__builtins__": {},
}
)
return ns
_NAMESPACE = _build_namespace()
def normalize(text: str) -> str:
"""Map common Unicode math notation to ASCII syntax."""
text = _SQRT.sub(r"sqrt(\1)", text)
text = text.replace("√", "sqrt")
for src, dst in _UNICODE.items():
text = text.replace(src, dst)
return text.strip()
def validate(text: str) -> str:
"""Reject anything that is not plain math before it reaches ``eval``."""
if not isinstance(text, str):
raise MathError("expression must be a string")
text = normalize(text)
if not text:
raise MathError("expression is empty")
if len(text) > limits().max_input_length:
raise MathError(f"expression is longer than {limits().max_input_length} characters")
if not _ALLOWED_CHARS.match(text):
bad = sorted({c for c in text if not _ALLOWED_CHARS.match(c)})
raise MathError(f"unsupported character(s): {' '.join(bad)}")
if "__" in text:
raise MathError("'__' is not allowed")
if _ATTRIBUTE_ACCESS.search(text):
raise MathError("attribute access ('.name') is not allowed; use function syntax, e.g. diff(f, x)")
for name in _IDENTIFIER.findall(text):
if name.startswith("_"):
raise MathError(f"names must not start with '_': {name}")
if keyword.iskeyword(name) and name not in _ALLOWED_KEYWORDS:
raise MathError(f"'{name}' is a reserved word and cannot be used as a name")
if len(name) > _MAX_IDENTIFIER_LENGTH:
raise MathError(f"name too long: {name[:20]}...")
return text
def parse(text: str, *, exact: bool = True, local: dict[str, object] | None = None) -> sp.Expr:
"""Parse an expression, equation (``a = b``) or inequality into a SymPy object.
``exact=True`` turns decimal literals into rationals.
"""
text = validate(text)
# '=' and '!=' are split off here: in Python code they would compare structurally.
lhs, op, rhs = _split_relation(text)
if op is None:
return _parse(text, exact, local)
relation = sp.Eq if op == "=" else sp.Ne
return relation(_parse(lhs, exact, local), _parse(rhs, exact, local))
def parse_symbol(name: str) -> sp.Symbol:
name = validate(name)
if not re.fullmatch(r"[^\W\d]\w*", name) or name in _NAMESPACE:
raise MathError(f"'{name}' is not a valid variable name")
return sp.Symbol(name)
def parse_symbols(names: Iterable[str] | str | None) -> list[sp.Symbol]:
if names is None:
return []
if isinstance(names, str):
names = [n for n in re.split(r"[,\s]+", names) if n]
return [parse_symbol(n) for n in names]
def as_equation(expr: sp.Basic) -> sp.Basic:
"""Treat a bare expression ``f`` as the equation ``f = 0``."""
if isinstance(expr, (sp.Equality, sp.Rel)):
return expr
return sp.Eq(expr, 0)
def apply_assumptions(expr: sp.Basic, assume: Assumption) -> tuple[sp.Basic, dict[sp.Symbol, sp.Symbol]]:
"""Replace free symbols by symbols carrying assumptions (real, positive, ...).
Returns the new expression and the mapping old -> new symbol.
"""
if assume == "none":
return expr, {}
flags = {"real": {"real": True}, "positive": {"positive": True}, "integer": {"integer": True}}[assume]
mapping = {s: sp.Symbol(s.name, **flags) for s in expr.free_symbols if isinstance(s, sp.Symbol)}
return expr.xreplace(mapping), mapping
def _split_relation(text: str) -> tuple[str, str | None, str]:
"""Split at a top-level '=' / '==' or '!=' (but not '<=' / '>=')."""
found: list[tuple[int, int, str]] = [] # (start, length, operator)
depth = 0
i = 0
while i < len(text):
ch = text[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
elif depth == 0 and ch == "!" and text[i + 1 : i + 2] == "=":
found.append((i, 2, "!="))
i += 2
continue
elif depth == 0 and ch == "=" and (i == 0 or text[i - 1] not in "<>"):
length = 2 if text[i + 1 : i + 2] == "=" else 1
found.append((i, length, "="))
i += length
continue
i += 1
if not found:
return text, None, ""
if len(found) > 1:
raise MathError("an equation may contain only one '='; pass several equations as a list")
start, length, op = found[0]
lhs, rhs = text[:start].strip(), text[start + length :].strip()
if not lhs or not rhs:
raise MathError(f"both sides of '{op}' must be non-empty")
return lhs, op, rhs
def _parse(text: str, exact: bool, local: dict[str, object] | None) -> sp.Expr:
transformations = _TRANSFORMATIONS if exact else _TRANSFORMATIONS_FLOAT
try:
result = parse_expr(text, local_dict=dict(local or {}), global_dict=dict(_NAMESPACE), transformations=transformations)
except SyntaxError as exc:
raise MathError(f"syntax error in {text!r}: {exc.msg}") from None
except (TypeError, ValueError, NameError, AttributeError) as exc:
raise MathError(f"could not parse {text!r}: {exc}") from None
if not isinstance(result, (sp.Basic, int, float, bool)):
raise MathError(f"{text!r} is not a math expression")
return sp.sympify(result)

View file

@ -0,0 +1,160 @@
"""API vocabulary shared by the MCP layer (server process) and the math operations (workers).
This module must stay import-light: no SymPy, NumPy, SciPy or Pint. The server
process imports it for tool schemas, but never loads the math libraries itself;
they only live in the worker processes (see docs/memory.md).
"""
from __future__ import annotations
from typing import Literal
Assumption = Literal["none", "real", "positive", "integer"]
Domain = Literal["complex", "real", "positive", "integer"]
Direction = Literal["+-", "+", "-"]
Alternative = Literal["two-sided", "less", "greater"]
TransformOperation = Literal[
"simplify",
"expand",
"factor",
"collect",
"cancel",
"apart",
"together",
"trigsimp",
"expand_trig",
"powsimp",
"expand_log",
"logcombine",
"radsimp",
"rationalize_denominator",
"nsimplify",
"substitute",
]
CalculusOperation = Literal[
"derivative",
"integral",
"limit",
"series",
"sum",
"product",
"gradient",
"hessian",
"jacobian",
]
MatrixOperation = Literal[
"determinant",
"inverse",
"transpose",
"rank",
"trace",
"rref",
"eigenvalues",
"eigenvectors",
"characteristic_polynomial",
"diagonalize",
"nullspace",
"columnspace",
"multiply",
"add",
"subtract",
"power",
"solve",
"dot",
"cross",
"norm",
"svd",
"pseudo_inverse",
"condition_number",
]
RegressionModel = Literal["linear", "polynomial", "exponential", "logarithmic", "power"]
Distribution = Literal[
"normal",
"t",
"chi2",
"f",
"exponential",
"uniform",
"lognormal",
"gamma",
"beta",
"binomial",
"poisson",
"geometric",
"hypergeometric",
"negative_binomial",
]
ProbabilityOperation = Literal["pdf", "cdf", "sf", "between", "quantile", "interval", "summary"]
HypothesisTest = Literal[
"one_sample_t",
"two_sample_t",
"paired_t",
"one_way_anova",
"mann_whitney",
"wilcoxon",
"chi2_goodness_of_fit",
"chi2_independence",
"shapiro_wilk",
"binomial",
"pearson_correlation",
]
NumberTheoryOperation = Literal[
"gcd",
"lcm",
"factorize",
"is_prime",
"next_prime",
"previous_prime",
"nth_prime",
"primes_in_range",
"divisors",
"totient",
"mod",
"mod_pow",
"mod_inverse",
"crt",
"factorial",
"binomial",
"permutations",
"fibonacci",
"continued_fraction",
"base_convert",
]
# Parameter names of each distribution, in signature order.
DISTRIBUTION_PARAMETERS: dict[str, list[str]] = {
"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"],
}
DISTRIBUTION_HELP = "; ".join(f"{name}({', '.join(params)})" for name, params in DISTRIBUTION_PARAMETERS.items())
SYNTAX_HELP = (
"Expression syntax: + - * / ^ (or **), implicit multiplication (2x, 3(x+1)), factorial n!, "
"modulo Mod(a, b) or a % b. Constants: pi, e, I (imaginary unit), oo (infinity), deg (pi/180, "
"so sin(30*deg)). Functions: sqrt, cbrt, root(x, n), exp, ln/log (log(x, b) for base b), log10, log2, "
"sin cos tan cot sec csc, asin acos atan atan2, sinh cosh tanh, abs, sign, floor, ceiling, "
"factorial, binomial, gamma, erf, min, max, gcd, lcm, re, im, conjugate. "
"Equations use a single '=', inequalities <, <=, >, >=. Use '.' as decimal separator; "
"decimals are treated as exact fractions."
)

View file

@ -0,0 +1,391 @@
"""Descriptive statistics, regression, probability distributions and hypothesis tests."""
from __future__ import annotations
import math
from typing import Any
import numpy as np
from scipy import stats as st
from mcp_calculator.ops.common import MathError, check_list, limits
from mcp_calculator.ops.spec import DISTRIBUTION_HELP, DISTRIBUTION_PARAMETERS, Alternative, Distribution, HypothesisTest, ProbabilityOperation, RegressionModel
def _clean(value: Any, digits: int = 12) -> Any:
"""Round floats for readability and make values JSON-safe."""
if isinstance(value, dict):
return {k: _clean(v, digits) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_clean(v, digits) for v in value]
if isinstance(value, np.ndarray):
return _clean(value.tolist(), digits)
if isinstance(value, (np.integer,)):
return int(value)
if isinstance(value, (float, np.floating)):
value = float(value)
if math.isnan(value):
return None
if math.isinf(value):
return "inf" if value > 0 else "-inf"
return float(f"{value:.{digits}g}")
return value
def _data(values: list[float], name: str = "data", min_items: int = 1) -> np.ndarray:
check_list(values, name, min_items=min_items, max_items=limits().max_data_points)
arr = np.asarray(values, dtype=float)
if not np.all(np.isfinite(arr)):
raise MathError(f"'{name}' contains non-finite values")
return arr
# --------------------------------------------------------------------------- descriptive
def statistics(data: list[float], sample: bool = True, percentiles: list[float] | None = None, y: list[float] | None = None) -> dict[str, Any]:
x = _data(data)
n = x.size
ddof = 1 if sample and n > 1 else 0
values, counts = np.unique(x, return_counts=True)
modes = values[counts == counts.max()] if counts.max() > 1 else np.array([])
q1, median, q3 = np.percentile(x, [25, 50, 75])
std = float(np.std(x, ddof=ddof))
out: dict[str, Any] = {
"count": n,
"sum": math.fsum(x),
"mean": math.fsum(x) / n,
"median": median,
"mode": modes.tolist(),
"min": x.min(),
"max": x.max(),
"range": x.max() - x.min(),
"variance": float(np.var(x, ddof=ddof)),
"std_dev": std,
"kind": "sample (n-1)" if ddof else "population (n)",
"q1": q1,
"q3": q3,
"iqr": q3 - q1,
}
if n > 1:
out["standard_error"] = std / math.sqrt(n)
out["coefficient_of_variation"] = std / out["mean"] if out["mean"] else None
if n > 2 and np.ptp(x) > 0:
out["skewness"] = float(st.skew(x, bias=not sample))
out["kurtosis_excess"] = float(st.kurtosis(x, bias=not sample)) if n > 3 else None
if np.all(x > 0):
out["geometric_mean"] = float(st.gmean(x))
out["harmonic_mean"] = float(st.hmean(x))
if percentiles:
check_list(percentiles, "percentiles", max_items=100)
if any(not 0 <= p <= 100 for p in percentiles):
raise MathError("percentiles must be between 0 and 100")
out["percentiles"] = {str(p): float(np.percentile(x, p)) for p in percentiles}
if y is not None:
yy = _data(y, "y")
if yy.size != n:
raise MathError("'data' and 'y' must have the same length")
if n > 2 and np.ptp(x) > 0 and np.ptp(yy) > 0:
pearson = st.pearsonr(x, yy)
spearman = st.spearmanr(x, yy)
out["paired"] = {
"covariance": float(np.cov(x, yy, ddof=ddof)[0, 1]),
"pearson_r": float(pearson.statistic),
"pearson_p_value": float(pearson.pvalue),
"spearman_rho": float(spearman.statistic),
"spearman_p_value": float(spearman.pvalue),
}
return _clean(out)
# --------------------------------------------------------------------------- regression
def regression(
x: list[float],
y: list[float],
model: RegressionModel = "linear",
degree: int = 2,
predict: list[float] | None = None,
) -> dict[str, Any]:
xs, ys = _data(x, "x", 2), _data(y, "y", 2)
if xs.size != ys.size:
raise MathError("'x' and 'y' must have the same length")
match model:
case "linear":
fit = st.linregress(xs, ys)
a, b = float(fit.intercept), float(fit.slope)
predict_fn = lambda v: a + b * v # noqa: E731
out: dict[str, Any] = {
"equation": f"y = {_g(a)} + {_g(b)}*x",
"coefficients": {"intercept": a, "slope": b},
"pearson_r": float(fit.rvalue),
"p_value": float(fit.pvalue),
"slope_std_error": float(fit.stderr),
"intercept_std_error": float(fit.intercept_stderr),
}
case "polynomial":
if not 1 <= degree <= 10:
raise MathError("degree must be between 1 and 10")
if xs.size <= degree:
raise MathError(f"a degree-{degree} polynomial needs at least {degree + 1} points")
coeffs = np.polyfit(xs, ys, degree)
predict_fn = lambda v: float(np.polyval(coeffs, v)) # noqa: E731
terms = [f"{_g(c)}*x^{degree - i}" if degree - i > 1 else (f"{_g(c)}*x" if degree - i == 1 else _g(c)) for i, c in enumerate(coeffs)]
out = {
"equation": "y = " + " + ".join(terms),
"coefficients": {f"x^{degree - i}": float(c) for i, c in enumerate(coeffs)},
}
case "exponential":
if np.any(ys <= 0):
raise MathError("exponential model y = a*e^(b*x) needs y > 0")
fit = st.linregress(xs, np.log(ys))
a, b = math.exp(fit.intercept), float(fit.slope)
predict_fn = lambda v: a * math.exp(b * v) # noqa: E731
out = {"equation": f"y = {_g(a)}*e^({_g(b)}*x)", "coefficients": {"a": a, "b": b}, "fitted_on": "ln(y) (log-linear least squares)"}
case "logarithmic":
if np.any(xs <= 0):
raise MathError("logarithmic model y = a + b*ln(x) needs x > 0")
fit = st.linregress(np.log(xs), ys)
a, b = float(fit.intercept), float(fit.slope)
predict_fn = lambda v: a + b * math.log(v) # noqa: E731
out = {"equation": f"y = {_g(a)} + {_g(b)}*ln(x)", "coefficients": {"a": a, "b": b}}
case "power":
if np.any(xs <= 0) or np.any(ys <= 0):
raise MathError("power model y = a*x^b needs x > 0 and y > 0")
fit = st.linregress(np.log(xs), np.log(ys))
a, b = math.exp(fit.intercept), float(fit.slope)
predict_fn = lambda v: a * v**b # noqa: E731
out = {"equation": f"y = {_g(a)}*x^{_g(b)}", "coefficients": {"a": a, "b": b}, "fitted_on": "ln(x), ln(y) (log-log least squares)"}
case _:
raise MathError(f"unknown model {model!r}")
fitted = np.array([predict_fn(v) for v in xs])
residuals = ys - fitted
ss_res = float(np.sum(residuals**2))
ss_tot = float(np.sum((ys - ys.mean()) ** 2))
out["model"] = model
out["r_squared"] = 1 - ss_res / ss_tot if ss_tot > 0 else None
out["rmse"] = math.sqrt(ss_res / xs.size)
out["n"] = int(xs.size)
if predict:
check_list(predict, "predict", max_items=1000)
try:
out["predictions"] = [{"x": v, "y": predict_fn(v)} for v in predict]
except (ValueError, OverflowError) as exc:
raise MathError(f"prediction failed: {exc}") from None
return _clean(out)
def _g(v: float) -> str:
return f"{v:.10g}"
def _n(v: float) -> float | int:
"""Show integral floats as ints (20.0 -> 20)."""
return int(v) if isinstance(v, float) and v.is_integer() else v
# --------------------------------------------------------------------------- distributions
# name: (defaults, factory(params) -> frozen scipy distribution); parameter names live in spec.DISTRIBUTION_PARAMETERS
_DISTRIBUTIONS: dict[str, tuple[dict[str, float], Any]] = {
"normal": ({"mean": 0.0, "std": 1.0}, lambda p: st.norm(p["mean"], p["std"])),
"t": ({}, lambda p: st.t(p["df"])),
"chi2": ({}, lambda p: st.chi2(p["df"])),
"f": ({}, lambda p: st.f(p["dfn"], p["dfd"])),
"exponential": ({"rate": 1.0}, lambda p: st.expon(scale=1 / p["rate"])),
"uniform": ({"low": 0.0, "high": 1.0}, lambda p: st.uniform(p["low"], p["high"] - p["low"])),
"lognormal": ({}, lambda p: st.lognorm(s=p["sigma"], scale=math.exp(p["mu"]))),
"gamma": ({"scale": 1.0}, lambda p: st.gamma(p["shape"], scale=p["scale"])),
"beta": ({}, lambda p: st.beta(p["a"], p["b"])),
"binomial": ({}, lambda p: st.binom(int(p["n"]), p["p"])),
"poisson": ({}, lambda p: st.poisson(p["lambda"])),
"geometric": ({}, lambda p: st.geom(p["p"])),
"hypergeometric": ({}, lambda p: st.hypergeom(int(p["population"]), int(p["successes"]), int(p["draws"]))),
"negative_binomial": ({}, lambda p: st.nbinom(p["successes"], p["p"])),
}
_DISCRETE = {"binomial", "poisson", "geometric", "hypergeometric", "negative_binomial"}
def probability(
distribution: Distribution,
operation: ProbabilityOperation,
parameters: dict[str, float] | None = None,
x: float | None = None,
lower: float | None = None,
upper: float | None = None,
q: float | None = None,
confidence: float = 0.95,
) -> dict[str, Any]:
if distribution not in _DISTRIBUTIONS:
raise MathError(f"unknown distribution {distribution!r}. Available: {DISTRIBUTION_HELP}")
defaults, factory = _DISTRIBUTIONS[distribution]
required = DISTRIBUTION_PARAMETERS[distribution]
params = {**defaults, **(parameters or {})}
missing = [r for r in required if r not in params]
if missing:
raise MathError(f"{distribution} needs parameter(s) {missing}. Signature: {distribution}({', '.join(required)})")
unknown = set(params) - set(required)
if unknown:
raise MathError(f"unknown parameter(s) {sorted(unknown)} for {distribution}({', '.join(required)})")
try:
dist = factory(params)
_ = dist.mean()
except (ValueError, ZeroDivisionError, TypeError) as exc:
raise MathError(f"invalid parameters for {distribution}: {exc}") from None
discrete = distribution in _DISCRETE
shown = {k: _n(v) for k, v in params.items()}
out: dict[str, Any] = {"distribution": distribution, "parameters": shown, "operation": operation, "discrete": discrete}
def need(value: float | None, name: str) -> float:
if value is None:
raise MathError(f"'{operation}' needs '{name}'")
return value
match operation:
case "pdf":
v = need(x, "x")
out["result"] = float(dist.pmf(v) if discrete else dist.pdf(v))
out["meaning"] = f"P(X = {_n(v)})" if discrete else f"density f({_n(v)})"
case "cdf":
v = need(x, "x")
out["result"] = float(dist.cdf(v))
out["meaning"] = f"P(X <= {_n(v)})"
case "sf":
v = need(x, "x")
out["result"] = float(dist.sf(v))
out["meaning"] = f"P(X > {_n(v)})" + (f" = P(X >= {math.floor(v) + 1})" if discrete else "")
case "between":
a, b = need(lower, "lower"), need(upper, "upper")
out["result"] = float(dist.cdf(b) - dist.cdf(a))
out["meaning"] = f"P({_n(a)} < X <= {_n(b)})" + (f" = P({math.floor(a) + 1} <= X <= {math.floor(b)})" if discrete else "")
case "quantile":
p = need(q, "q")
if not 0 < p < 1:
raise MathError("q must be between 0 and 1 (exclusive)")
out["result"] = float(dist.ppf(p))
out["meaning"] = f"smallest x with P(X <= x) >= {_n(p)}"
case "interval":
if not 0 < confidence < 1:
raise MathError("confidence must be between 0 and 1")
lo, hi = dist.interval(confidence)
out["result"] = [float(lo), float(hi)]
out["meaning"] = f"central interval containing {confidence:.0%} of the probability mass"
case "summary":
out["result"] = {
"mean": float(dist.mean()),
"variance": float(dist.var()),
"std_dev": float(dist.std()),
"median": float(dist.median()),
"skewness": float(dist.stats(moments="s")),
"kurtosis_excess": float(dist.stats(moments="k")),
}
case _:
raise MathError(f"unknown operation {operation!r}")
return _clean(out)
# --------------------------------------------------------------------------- hypothesis tests
def hypothesis_test(
test: HypothesisTest,
sample: list[float] | None = None,
sample2: list[float] | None = None,
groups: list[list[float]] | None = None,
table: list[list[float]] | None = None,
expected: list[float] | None = None,
mu: float = 0.0,
p: float = 0.5,
alternative: Alternative = "two-sided",
equal_variance: bool = False,
alpha: float = 0.05,
) -> dict[str, Any]:
if not 0 < alpha < 1:
raise MathError("alpha must be between 0 and 1")
def need(values: list[float] | None, name: str, min_items: int = 2) -> np.ndarray:
if values is None:
raise MathError(f"'{test}' needs '{name}'")
return _data(values, name, min_items)
out: dict[str, Any] = {"test": test, "alternative": alternative, "alpha": alpha}
match test:
case "one_sample_t":
r = st.ttest_1samp(need(sample, "sample"), mu, alternative=alternative)
out.update(statistic=r.statistic, p_value=r.pvalue, df=r.df, null_hypothesis=f"mean = {mu}")
ci = r.confidence_interval(1 - alpha)
out["confidence_interval"] = [ci.low, ci.high]
case "two_sample_t":
r = st.ttest_ind(need(sample, "sample"), need(sample2, "sample2"), equal_var=equal_variance, alternative=alternative)
out.update(statistic=r.statistic, p_value=r.pvalue, df=r.df, variant="Student" if equal_variance else "Welch")
out["null_hypothesis"] = "mean(sample) = mean(sample2)"
case "paired_t":
a, b = need(sample, "sample"), need(sample2, "sample2")
if a.size != b.size:
raise MathError("paired samples must have the same length")
r = st.ttest_rel(a, b, alternative=alternative)
out.update(statistic=r.statistic, p_value=r.pvalue, df=r.df, null_hypothesis="mean difference = 0")
case "one_way_anova":
if not groups or len(groups) < 2:
raise MathError("'one_way_anova' needs 'groups' with at least two groups")
r = st.f_oneway(*[_data(g, "groups[i]", 2) for g in groups])
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="all group means are equal")
out["alternative"] = "at least one mean differs"
case "mann_whitney":
r = st.mannwhitneyu(need(sample, "sample"), need(sample2, "sample2"), alternative=alternative)
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="both samples come from the same distribution")
case "wilcoxon":
a = need(sample, "sample")
r = st.wilcoxon(a, need(sample2, "sample2"), alternative=alternative) if sample2 is not None else st.wilcoxon(a - mu, alternative=alternative)
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="median difference = 0" if sample2 is not None else f"median = {mu}")
case "chi2_goodness_of_fit":
observed = need(sample, "sample (observed counts)")
exp = _data(expected, "expected", 2) if expected is not None else None
if exp is not None:
if exp.size != observed.size:
raise MathError("'expected' must have the same length as the observed counts")
exp = exp * observed.sum() / exp.sum() # accept probabilities or counts
r = st.chisquare(observed, exp)
out.update(statistic=r.statistic, p_value=r.pvalue, df=observed.size - 1, null_hypothesis="observed counts follow the expected distribution")
out["alternative"] = "two-sided"
case "chi2_independence":
if not table:
raise MathError("'chi2_independence' needs 'table' (contingency table of counts)")
arr = np.asarray(table, dtype=float)
r = st.chi2_contingency(arr)
out.update(statistic=r.statistic, p_value=r.pvalue, df=r.dof, expected_frequencies=r.expected_freq, null_hypothesis="rows and columns are independent")
n = arr.sum()
out["cramers_v"] = math.sqrt(r.statistic / (n * (min(arr.shape) - 1))) if min(arr.shape) > 1 else None
out["alternative"] = "two-sided"
case "shapiro_wilk":
r = st.shapiro(need(sample, "sample", 3))
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="data is normally distributed")
out["alternative"] = "two-sided"
case "binomial":
values = need(sample, "sample ([successes, trials])", 2)
if values.size != 2:
raise MathError("for 'binomial' pass sample = [successes, trials]")
r = st.binomtest(int(values[0]), int(values[1]), p, alternative=alternative)
ci = r.proportion_ci(1 - alpha)
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis=f"success probability = {p}", confidence_interval=[ci.low, ci.high])
case "pearson_correlation":
a, b = need(sample, "sample", 3), need(sample2, "sample2", 3)
r = st.pearsonr(a, b, alternative=alternative)
ci = r.confidence_interval(1 - alpha)
out.update(statistic=r.statistic, p_value=r.pvalue, null_hypothesis="correlation = 0", confidence_interval=[ci.low, ci.high])
case _:
raise MathError(f"unknown test {test!r}")
p_value = float(out["p_value"])
out["reject_null"] = p_value < alpha
out["conclusion"] = (
f"p = {p_value:.4g} < {alpha}: reject the null hypothesis ({out['null_hypothesis']})."
if p_value < alpha
else f"p = {p_value:.4g} >= {alpha}: the null hypothesis ({out['null_hypothesis']}) cannot be rejected."
)
return _clean(out)

View file

@ -0,0 +1,63 @@
"""Unit conversion and dimensional arithmetic via Pint.
Pint parses quantity strings with its own tokenizer (no ``eval``), so user input
never reaches Python evaluation here.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Any
import pint
from mcp_calculator.ops.common import MathError, limits
@lru_cache(maxsize=1)
def _registry() -> pint.UnitRegistry:
return pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
def convert_units(quantity: str, to_unit: str | None = None, precision: int = 12) -> dict[str, Any]:
if len(quantity) > limits().max_input_length or (to_unit and len(to_unit) > 200):
raise MathError("input too long")
if not 1 <= precision <= 30:
raise MathError("precision must be between 1 and 30")
ureg = _registry()
try:
q = ureg.Quantity(quantity)
except (pint.errors.UndefinedUnitError, pint.errors.DefinitionSyntaxError) as exc:
raise MathError(f"unknown unit in {quantity!r}: {exc}") from None
except (pint.errors.OffsetUnitCalculusError, ValueError, TypeError, AttributeError, SyntaxError) as exc:
raise MathError(f"could not interpret {quantity!r}: {exc}") from None
if not isinstance(q, pint.Quantity):
q = ureg.Quantity(q)
try:
if to_unit:
result = q.to(to_unit)
else:
result = q.to_base_units()
except pint.errors.DimensionalityError as exc:
raise MathError(f"incompatible dimensions: cannot convert {exc.dim1} to {exc.dim2}") from None
except pint.errors.UndefinedUnitError as exc:
raise MathError(f"unknown target unit {to_unit!r}: {exc}") from None
except pint.errors.OffsetUnitCalculusError as exc:
raise MathError(f"offset units (degC, degF) cannot be combined this way: {exc}") from None
magnitude = result.magnitude
out: dict[str, Any] = {
"input": quantity,
"result": f"{magnitude:.{precision}g} {result.units:~P}".strip(),
"magnitude": float(f"{magnitude:.{precision}g}"),
"unit": f"{result.units:~P}",
"unit_long": f"{result.units:P}",
"dimensionality": str(result.dimensionality),
}
if not to_unit:
out["note"] = "No target unit given; converted to SI base units."
compact = q.to_compact() if q.dimensionless is False else q
out["compact"] = f"{compact.magnitude:.{precision}g} {compact.units:~P}"
return out

View file

@ -0,0 +1,54 @@
"""Per-client concurrency limit (overload protection).
A single client (e.g. an agent stuck in a loop) must not occupy the whole capacity
of the pod. Each client may run at most ``max_concurrent`` computations at once
(per client setting, otherwise ``MCP_CALC_CLIENT_MAX_CONCURRENT``; 0 = unlimited).
Requests above the limit are rejected immediately as "busy".
Counts are per process/replica. That matches the goal (every pod protects itself)
and needs no shared state.
"""
from __future__ import annotations
from collections import defaultdict
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from mcp_calculator import metrics
from mcp_calculator.auth import Identity
from mcp_calculator.executor import ComputationError
class ClientConcurrencyLimiter:
def __init__(self, default_limit: int) -> None:
self.default_limit = default_limit
self._active: defaultdict[str, int] = defaultdict(int) # only touched from the event loop
def limit_for(self, identity: Identity) -> int:
if not identity.limited:
return 0 # stdio: a single local user, no limit
return self.default_limit if identity.max_concurrent is None else identity.max_concurrent
def active(self, name: str) -> int:
return self._active.get(name, 0)
@asynccontextmanager
async def slot(self, identity: Identity) -> AsyncIterator[None]:
limit = self.limit_for(identity)
name = identity.name
if limit and self._active[name] >= limit:
raise ComputationError(
f"client busy: {name!r} already has {self._active[name]} computations running (limit {limit}). "
"Wait for them to finish or retry in a few seconds.",
kind="busy",
)
self._active[name] += 1
metrics.CLIENT_INFLIGHT.labels(name).set(self._active[name])
try:
yield
finally:
self._active[name] -= 1
metrics.CLIENT_INFLIGHT.labels(name).set(self._active[name])
if not self._active[name]:
del self._active[name]

View file

@ -0,0 +1,383 @@
"""MCP server definition: tool schemas and descriptions.
Tool functions here only validate/normalise arguments and hand the work to the
executor; the math lives in ``mcp_calculator.ops`` and runs in worker processes.
This module deliberately imports only ``ops.spec`` (no SymPy/SciPy), which keeps
the server process small (see docs/memory.md).
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Annotated, Any
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
from pydantic import Field
from mcp_calculator import __version__, metrics
from mcp_calculator.auth import current_identity
from mcp_calculator.executor import ComputationError, Executor
from mcp_calculator.ops import spec
from mcp_calculator.ops.spec import DISTRIBUTION_HELP, SYNTAX_HELP
from mcp_calculator.ratelimit import ClientConcurrencyLimiter
logger = logging.getLogger("mcp_calculator.tools")
INSTRUCTIONS = f"""\
Exact, deterministic math tools. Use them instead of mental arithmetic whenever a
result must be correct: any arithmetic beyond trivial, algebra, calculus, linear
algebra, statistics, probabilities, number theory and unit conversions.
Which tool:
- evaluate: compute a value (exact + decimal, arbitrary precision)
- transform: simplify / expand / factor / substitute ... an expression
- solve: equations, systems, inequalities (symbolic or numeric)
- calculus: derivative, integral, limit, series, sum, product, gradient, hessian, jacobian
- analyze_function: complete curve sketching of f(x) (zeros, extrema, inflection points, asymptotes)
- solve_ode: ordinary differential equations, optionally with initial conditions
- matrix: determinants, inverses, eigenvalues, linear systems, decompositions
- statistics / regression / probability / hypothesis_test: data analysis and distributions
- number_theory: primes, gcd/lcm, modular arithmetic, combinatorics, base conversion
- convert_units: physical units and quantity arithmetic
{SYNTAX_HELP}
Every symbolic result field 'result' uses this same syntax, so it can be passed to
the next tool call unchanged. Report results to the user from the tool output; do
not recompute them by hand. If a tool times out, simplify the problem or use a
numeric option.
"""
READ_ONLY = ToolAnnotations(readOnlyHint=True, idempotentHint=True, destructiveHint=False, openWorldHint=False)
Expression = Annotated[str, Field(description="Math expression, e.g. 'x^2 + 2x + 1' or 'sqrt(2)*pi'", max_length=20_000)]
Precision = Annotated[int, Field(description="Significant digits of decimal results", ge=1, le=1000)]
Number = str | float | int
def create_server(executor: Executor, limiter: ClientConcurrencyLimiter | None = None) -> MCPServer:
limiter = limiter or ClientConcurrencyLimiter(executor.settings.client_max_concurrent)
mcp = MCPServer(
name="calculator",
title="Calculator",
description="Exact math for LLMs: symbolic algebra, calculus, linear algebra, statistics, units.",
instructions=INSTRUCTIONS,
version=__version__,
)
async def run(tool: str, task: str, **kwargs: Any) -> dict[str, Any]:
identity = current_identity.get()
started = time.perf_counter()
status = "ok"
try:
async with limiter.slot(identity):
return await executor.run(task, **kwargs)
except ComputationError as exc:
status = exc.kind
logger.debug("tool=%s client=%s error=%r", tool, identity.name, str(exc))
raise ToolError(str(exc)) from None
except asyncio.CancelledError:
status = "cancelled"
raise
except Exception:
status = "crashed"
raise
finally:
elapsed = time.perf_counter() - started
metrics.TOOL_CALLS.labels(tool, identity.name, status).inc()
metrics.TOOL_DURATION.labels(tool).observe(elapsed)
logger.info("tool=%s client=%s status=%s duration_ms=%.0f", tool, identity.name, status, elapsed * 1000)
# ------------------------------------------------------------------ algebra
@mcp.tool(annotations=READ_ONLY)
async def evaluate(
expression: Expression,
precision: Precision = 15,
variables: Annotated[
dict[str, Number] | None,
Field(description="Values to substitute before evaluating, e.g. {\"x\": 2, \"r\": \"1/3\"}"),
] = None,
) -> dict[str, Any]:
"""Evaluate an expression exactly and as a decimal number with arbitrary precision.
Use for any arithmetic, powers, roots, logarithms, trig values, factorials, big integers.
Decimals are exact fractions, so 0.1 + 0.2 is exactly 3/10.
Examples: "2^64", "sqrt(8) + 1/3", "sin(30*deg)", "100!", "e^(I*pi)", "(1 + 0.05)^10 * 1000".
Returns 'exact' (symbolic) and 'numeric' (decimal string with the requested precision).
"""
return await run("evaluate", "algebra.evaluate", expression=expression, precision=precision, variables=variables)
@mcp.tool(annotations=READ_ONLY)
async def transform(
expression: Expression,
operation: Annotated[
spec.TransformOperation,
Field(description="simplify | expand | factor | collect (needs variable) | cancel | apart (partial fractions) | together | trigsimp | expand_trig | powsimp | expand_log | logcombine | radsimp / rationalize_denominator | nsimplify (decimal -> closed form) | substitute"),
] = "simplify",
variable: Annotated[str | None, Field(description="Main variable for collect / apart / factor")] = None,
substitutions: Annotated[dict[str, Number] | None, Field(description="For 'substitute': {\"x\": \"2*t\", \"y\": 3}")] = None,
assume: Annotated[
spec.Assumption,
Field(description="Assumption for all variables; 'positive' allows e.g. sqrt(x^2) -> x, log(a*b) -> log(a)+log(b)"),
] = "none",
) -> dict[str, Any]:
"""Rewrite an expression symbolically: simplify, expand, factor, partial fractions, substitute, ...
Examples: factor "x^2 - 5x + 6" -> (x-3)(x-2); simplify "sin(x)^2 + cos(x)^2" -> 1;
apart "1/(x^2-1)"; nsimplify "1.4142135623731" -> sqrt(2).
'equivalent_to_input' confirms the rewrite is mathematically identical.
"""
return await run("transform", "algebra.transform", expression=expression, operation=operation, variable=variable, substitutions=substitutions, assume=assume)
@mcp.tool(annotations=READ_ONLY)
async def solve(
equations: Annotated[
list[str] | str,
Field(description="One equation or a list forming a system. 'x^2 = 4', '2x + y = 1'. A bare expression means '= 0'. Inequalities: 'x^2 - 4 < 0'."),
],
variables: Annotated[list[str] | None, Field(description="Variables to solve for (default: all free variables)")] = None,
domain: Annotated[
spec.Domain,
Field(description="Domain of the variables. Use 'real' for most real-world problems."),
] = "complex",
numeric_only: Annotated[
bool,
Field(description="Solve numerically (Newton / polynomial roots). Use for transcendental equations without closed form, e.g. cos(x) = x."),
] = False,
initial_guess: Annotated[list[float] | None, Field(description="Start values for numeric solving, one per variable")] = None,
precision: Precision = 15,
) -> dict[str, Any]:
"""Solve equations, systems of equations and inequalities exactly (or numerically).
Examples: "x^2 - 5x + 6 = 0"; ["x + y = 10", "x - y = 2"]; "sin(x) = 1/2" (domain real) returns
all solutions as a set plus principal values; "x^2 < 4" returns the interval (-2, 2).
Returns 'solutions' (list of {variable: value}) and 'numeric' approximations.
"""
eqs = [equations] if isinstance(equations, str) else equations
return await run("solve", "algebra.solve", equations=eqs, variables=variables, domain=domain, numeric_only=numeric_only, initial_guess=initial_guess, precision=precision)
# ------------------------------------------------------------------ calculus
# Python name differs from the tool name to avoid clashing with the imported module.
@mcp.tool(name="calculus", annotations=READ_ONLY)
async def calculus_tool(
operation: Annotated[
spec.CalculusOperation,
Field(description="derivative | integral | limit | series | sum | product | gradient | hessian | jacobian"),
],
expression: Annotated[str, Field(description="Expression; for 'jacobian' a comma-separated list of component functions", max_length=20_000)],
variable: Annotated[
str,
Field(description="Variable, e.g. 'x'. Several comma-separated for mixed partial derivatives, gradient, hessian, jacobian: 'x, y'"),
] = "x",
order: Annotated[int, Field(description="Derivative order", ge=1, le=50)] = 1,
lower: Annotated[str | None, Field(description="Lower bound (integral, sum, product); may be '-oo'")] = None,
upper: Annotated[str | None, Field(description="Upper bound (integral, sum, product); may be 'oo'")] = None,
point: Annotated[str | None, Field(description="Limit point or series expansion point, e.g. '0', 'oo', 'pi/2'")] = None,
direction: Annotated[spec.Direction, Field(description="Limit direction: two-sided, from the right (+) or left (-)")] = "+-",
terms: Annotated[int, Field(description="Series: order of the truncation term O(x^terms)", ge=1, le=50)] = 6,
assume: Annotated[spec.Assumption, Field(description="Assumption for all variables")] = "none",
precision: Precision = 15,
) -> dict[str, Any]:
"""Derivatives, integrals (indefinite, definite, numeric fallback), limits, Taylor series, sums and products, gradient/Hessian/Jacobian.
Examples: derivative of "x^3*sin(x)"; integral of "e^(-x^2)" from "-oo" to "oo" -> sqrt(pi);
limit of "sin(x)/x" at "0"; sum of "1/n^2" over variable "n" from "1" to "oo" -> pi^2/6;
series of "exp(x)" at "0"; hessian of "x^2*y + y^3" with variable "x, y".
"""
return await run(
"calculus", "calculus.calculus",
operation=operation, expression=expression, variable=variable, order=order, lower=lower, upper=upper,
point=point, direction=direction, terms=terms, assume=assume, precision=precision,
) # fmt: skip
@mcp.tool(annotations=READ_ONLY)
async def analyze_function(
expression: Annotated[str, Field(description="Real function of one variable, e.g. 'x^3 - 3x' or '(x^2 - 1)/(x - 2)'", max_length=5_000)],
variable: Annotated[str, Field(description="The function's variable")] = "x",
precision: Annotated[int, Field(description="Significant digits of decimal values", ge=1, le=50)] = 10,
) -> dict[str, Any]:
"""Complete curve sketching of a real function f(x) in one call.
Returns domain, derivatives f', f'', f''', zeros, y-intercept, extrema (with type), inflection points,
symmetry, limits at +-infinity, vertical asymptotes, monotonicity and convexity intervals.
"""
return await run("analyze_function", "calculus.analyze_function", expression=expression, variable=variable, precision=precision)
@mcp.tool(annotations=READ_ONLY)
async def solve_ode(
equation: Annotated[
str,
Field(description="ODE using prime notation, e.g. \"y'' + 2y' + y = sin(x)\" or \"y' = k*y\"", max_length=5_000),
],
function: Annotated[str, Field(description="Name of the unknown function")] = "y",
variable: Annotated[str, Field(description="Independent variable")] = "x",
initial_conditions: Annotated[
dict[str, Number] | None,
Field(description="E.g. {\"y(0)\": 1, \"y'(0)\": 0}"),
] = None,
) -> dict[str, Any]:
"""Solve an ordinary differential equation symbolically, optionally with initial conditions.
Without initial conditions the general solution with constants C1, C2, ... is returned.
"""
return await run("solve_ode", "calculus.solve_ode", equation=equation, function=function, variable=variable, initial_conditions=initial_conditions)
# ------------------------------------------------------------------ linear algebra
@mcp.tool(annotations=READ_ONLY)
async def matrix(
operation: Annotated[
spec.MatrixOperation,
Field(description="determinant | inverse | transpose | rank | trace | rref | eigenvalues | eigenvectors | characteristic_polynomial | diagonalize | nullspace | columnspace | multiply | add | subtract | power | solve (A*x = other) | dot | cross | norm | svd | pseudo_inverse | condition_number"),
],
matrix: Annotated[
list[list[Number]] | list[Number],
Field(description="Matrix as list of rows, e.g. [[1, 2], [3, 4]]. Entries may be numbers or expressions ('1/3', 'a', 'sqrt(2)'). A flat list is a column vector."),
],
other: Annotated[
list[list[Number]] | list[Number] | None,
Field(description="Second operand for multiply / add / subtract / dot / cross, or right-hand side b for solve"),
] = None,
exponent: Annotated[int | None, Field(description="Exponent for 'power' (negative = power of the inverse)")] = None,
numeric_mode: Annotated[
bool,
Field(description="Use fast floating point (NumPy) instead of exact arithmetic; for large or ill-conditioned numeric matrices"),
] = False,
precision: Precision = 15,
) -> dict[str, Any]:
"""Linear algebra with exact arithmetic (fractions, roots, symbols) or fast floating point.
Examples: determinant [[1,2],[3,4]] -> -2; solve [[2,1],[1,3]] with other [3,5];
eigenvalues [[2,1],[1,2]]; determinant [["a","b"],["c","d"]] -> a*d - b*c.
"""
return await run("matrix", "linalg.matrix", operation=operation, matrix=matrix, other=other, exponent=exponent, numeric_mode=numeric_mode, precision=precision)
# ------------------------------------------------------------------ statistics
@mcp.tool(annotations=READ_ONLY)
async def statistics(
data: Annotated[list[float], Field(description="Numeric data values")],
sample: Annotated[bool, Field(description="true: sample statistics (n-1); false: population (n)")] = True,
percentiles: Annotated[list[float] | None, Field(description="Extra percentiles to compute, e.g. [5, 95]")] = None,
y: Annotated[list[float] | None, Field(description="Optional paired second variable: adds covariance, Pearson and Spearman correlation")] = None,
) -> dict[str, Any]:
"""Descriptive statistics of a data set in one call.
Returns count, sum, mean, median, mode, min/max, range, variance, standard deviation, quartiles, IQR,
standard error, skewness, kurtosis, geometric/harmonic mean; with 'y' also correlations.
"""
return await run("statistics", "stats.statistics", data=data, sample=sample, percentiles=percentiles, y=y)
@mcp.tool(annotations=READ_ONLY)
async def regression(
x: Annotated[list[float], Field(description="Independent values")],
y: Annotated[list[float], Field(description="Dependent values (same length as x)")],
model: Annotated[
spec.RegressionModel,
Field(description="linear (y=a+bx) | polynomial (needs degree) | exponential (y=a*e^(bx)) | logarithmic (y=a+b*ln x) | power (y=a*x^b)"),
] = "linear",
degree: Annotated[int, Field(description="Polynomial degree", ge=1, le=10)] = 2,
predict: Annotated[list[float] | None, Field(description="x values to predict y for")] = None,
) -> dict[str, Any]:
"""Least-squares curve fitting with coefficients, fitted equation, R², RMSE and optional predictions.
The linear model also returns Pearson r, p-value and standard errors.
"""
return await run("regression", "stats.regression", x=x, y=y, model=model, degree=degree, predict=predict)
@mcp.tool(annotations=READ_ONLY)
async def probability(
distribution: Annotated[spec.Distribution, Field(description=f"Distribution and its parameters: {DISTRIBUTION_HELP}")],
operation: Annotated[
spec.ProbabilityOperation,
Field(description="pdf (density, or P(X=x) for discrete) | cdf P(X<=x) | sf P(X>x) | between P(lower<X<=upper) | quantile (inverse cdf at q) | interval (central interval with 'confidence') | summary (mean, variance, ...)"),
],
parameters: Annotated[dict[str, float] | None, Field(description="Distribution parameters, e.g. {\"mean\": 100, \"std\": 15} or {\"n\": 10, \"p\": 0.3}")] = None,
x: Annotated[float | None, Field(description="Point for pdf / cdf / sf")] = None,
lower: Annotated[float | None, Field(description="Lower bound for 'between'")] = None,
upper: Annotated[float | None, Field(description="Upper bound for 'between'")] = None,
q: Annotated[float | None, Field(description="Probability for 'quantile', 0 < q < 1")] = None,
confidence: Annotated[float, Field(description="Coverage for 'interval'", gt=0, lt=1)] = 0.95,
) -> dict[str, Any]:
"""Probabilities, densities, quantiles and moments of common distributions.
Discrete distributions: P(X >= k) = sf at x = k-1; the 'meaning' field states exactly what was computed.
Examples: normal {mean 100, std 15} between 85 and 115; binomial {n 10, p 0.5} cdf at 3; t {df 9} quantile 0.975.
"""
return await run("probability", "stats.probability", distribution=distribution, operation=operation, parameters=parameters, x=x, lower=lower, upper=upper, q=q, confidence=confidence)
@mcp.tool(annotations=READ_ONLY)
async def hypothesis_test(
test: Annotated[
spec.HypothesisTest,
Field(description="one_sample_t (sample, mu) | two_sample_t (sample, sample2, equal_variance) | paired_t (sample, sample2) | one_way_anova (groups) | mann_whitney (sample, sample2) | wilcoxon (sample, sample2 or mu) | chi2_goodness_of_fit (sample = observed counts, expected) | chi2_independence (table) | shapiro_wilk (sample) | binomial (sample = [successes, trials], p) | pearson_correlation (sample, sample2)"),
],
sample: Annotated[list[float] | None, Field(description="First sample")] = None,
sample2: Annotated[list[float] | None, Field(description="Second sample")] = None,
groups: Annotated[list[list[float]] | None, Field(description="Groups for ANOVA")] = None,
table: Annotated[list[list[float]] | None, Field(description="Contingency table of counts for chi2_independence")] = None,
expected: Annotated[list[float] | None, Field(description="Expected counts or probabilities for chi2_goodness_of_fit (default: uniform)")] = None,
mu: Annotated[float, Field(description="Hypothesised mean/median for one-sample tests")] = 0.0,
p: Annotated[float, Field(description="Hypothesised success probability for the binomial test", ge=0, le=1)] = 0.5,
alternative: Annotated[spec.Alternative, Field(description="Alternative hypothesis")] = "two-sided",
equal_variance: Annotated[bool, Field(description="two_sample_t: Student (true) or Welch (false, recommended)")] = False,
alpha: Annotated[float, Field(description="Significance level", gt=0, lt=1)] = 0.05,
) -> dict[str, Any]:
"""Statistical hypothesis tests with test statistic, p-value, decision and a plain-language conclusion."""
return await run(
"hypothesis_test", "stats.hypothesis_test",
test=test, sample=sample, sample2=sample2, groups=groups, table=table, expected=expected,
mu=mu, p=p, alternative=alternative, equal_variance=equal_variance, alpha=alpha,
) # fmt: skip
# ------------------------------------------------------------------ number theory & units
@mcp.tool(name="number_theory", annotations=READ_ONLY)
async def number_theory_tool(
operation: Annotated[
spec.NumberTheoryOperation,
Field(description="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 [residues] + moduli | binomial, permutations [n, k] | continued_fraction [x] | base_convert [value] + from_base, to_base"),
],
values: Annotated[
list[int | str],
Field(description="Operands as listed for the operation. Pass integers above 2^53 as strings to keep every digit."),
],
moduli: Annotated[list[int | str] | None, Field(description="Moduli for 'crt'")] = None,
from_base: Annotated[int, Field(description="Source base for base_convert", ge=2, le=36)] = 10,
to_base: Annotated[int, Field(description="Target base for base_convert", ge=2, le=36)] = 10,
) -> dict[str, Any]:
"""Exact integer math: primes, factorization, gcd/lcm, modular arithmetic, Chinese remainder theorem, combinatorics, base conversion.
Examples: factorize [360] -> 2^3 * 3^2 * 5; mod_pow [2, 100, 97]; binomial [49, 6]; base_convert ["ff"] from 16 to 2.
"""
return await run("number_theory", "number_theory.number_theory", operation=operation, values=values, moduli=moduli, from_base=from_base, to_base=to_base)
@mcp.tool(annotations=READ_ONLY)
async def convert_units(
quantity: Annotated[
str,
Field(description="Quantity with unit, optionally with arithmetic: '100 km/h', '25 degC', '3 kWh', '5 km/h * 2 h', '1.5 kg * 9.81 m/s^2'", max_length=2_000),
],
to_unit: Annotated[str | None, Field(description="Target unit, e.g. 'm/s', 'degF', 'J', 'mi'. Omit for SI base units.")] = None,
precision: Annotated[int, Field(description="Significant digits", ge=1, le=30)] = 12,
) -> dict[str, Any]:
"""Convert physical quantities between units and compute with units (dimension-checked).
Supports SI and imperial units, prefixes, temperatures (degC, degF, K), data sizes (B, KiB, MB), energy, pressure, etc.
"""
return await run("convert_units", "units.convert_units", quantity=quantity, to_unit=to_unit, precision=precision)
# ------------------------------------------------------------------ reference resource
@mcp.resource("calculator://syntax", name="syntax", title="Expression syntax", mime_type="text/plain")
def syntax() -> str:
"""Syntax accepted by all expression parameters."""
return f"{SYNTAX_HELP}\n\nDistributions: {DISTRIBUTION_HELP}\n"
return mcp

5
tests/__init__.py Normal file
View file

@ -0,0 +1,5 @@
import logging
# Configure logging before any MCPServer is created; otherwise the SDK installs its own
# verbose handler and every test prints INFO lines.
logging.basicConfig(level=logging.WARNING)

102
tests/support.py Normal file
View file

@ -0,0 +1,102 @@
"""Shared test helpers (standard library ``unittest`` only).
Run all tests: python -m unittest
Skip subprocess tests: MCP_CALC_SKIP_SLOW=1 python -m unittest
"""
from __future__ import annotations
import asyncio
import json
import math
import os
import unittest
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from mcp import Client
from mcp_calculator.config import Limits, load_settings
from mcp_calculator.executor import Executor
from mcp_calculator.ops.context import set_limits
from mcp_calculator.server import create_server
# Tests that start subprocesses, worker pools or servers (a few seconds in total).
slow = unittest.skipIf(os.environ.get("MCP_CALC_SKIP_SLOW") == "1", "slow test skipped (MCP_CALC_SKIP_SLOW=1)")
class MathTestCase(unittest.TestCase):
"""Resets the global limits around every test."""
def setUp(self) -> None:
set_limits(Limits())
self.addCleanup(set_limits, Limits())
def assertApprox(self, actual: float, expected: float, rel: float = 1e-6) -> None:
self.assertTrue(math.isclose(actual, expected, rel_tol=rel, abs_tol=1e-12), f"{actual!r} != approx {expected!r}")
@asynccontextmanager
async def in_process_client(**env: str) -> AsyncIterator[Client]:
"""MCP client connected in-process to a server with the inline executor (fast, no subprocesses).
Opened inside each test: anyio cancel scopes must be entered and exited in the same task,
which rules out asyncSetUp/asyncTearDown.
"""
settings = load_settings([], env={"MCP_CALC_EXECUTOR": "inline", **env})
async with Client(create_server(Executor(settings))) as client:
yield client
async def asgi_request(app, method: str, path: str, *, headers: dict[str, str] | None = None, body: Any = None) -> tuple[int, Any]:
"""Call an ASGI app directly (no HTTP server, no extra dependency) and return (status, JSON body)."""
payload = json.dumps(body).encode() if body is not None else b""
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": method,
"scheme": "http",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"root_path": "",
"headers": [(b"content-type", b"application/json")] + [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()],
"client": ("127.0.0.1", 50000),
"server": ("testserver", 80),
}
delivered = False
async def receive():
nonlocal delivered
if not delivered:
delivered = True
return {"type": "http.request", "body": payload, "more_body": False}
await asyncio.Event().wait()
messages = []
async def send(message):
messages.append(message)
await app(scope, receive, send)
status = next(m["status"] for m in messages if m["type"] == "http.response.start")
raw = b"".join(m.get("body", b"") for m in messages if m["type"] == "http.response.body")
return status, json.loads(raw) if raw else None
class ClientTestCase(unittest.IsolatedAsyncioTestCase):
async def call(self, tool: str, **arguments: Any) -> dict[str, Any]:
async with in_process_client() as client:
result = await client.call_tool(tool, arguments)
self.assertFalse(result.is_error, result.content[0].text)
self.assertIsNotNone(result.structured_content)
self.assertEqual(json.loads(result.content[0].text), result.structured_content)
return result.structured_content
async def call_error(self, tool: str, **arguments: Any) -> str:
async with in_process_client() as client:
result = await client.call_tool(tool, arguments)
self.assertTrue(result.is_error, result.structured_content)
return result.content[0].text

171
tests/test_admin.py Normal file
View file

@ -0,0 +1,171 @@
"""Client administration: service layer, audit log, admin API and CLI."""
import contextlib
import io
import json
import tempfile
import unittest
from pathlib import Path
from mcp_calculator import admin_cli
from mcp_calculator.admin import AuditLog, ClientAdmin, Conflict, NotFound, build_admin_app
from mcp_calculator.clients import ClientConfigError, ClientFile, hash_token
from tests.support import asgi_request
ADMIN_TOKEN = "admin-token-0123456789"
class AdminTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = Path(self.tmp.name)
self.file = ClientFile(self.dir / "clients.json")
self.audit_path = self.dir / "audit.log"
self.changes = 0
self.admin = ClientAdmin(self.file, AuditLog(self.audit_path), on_change=self._on_change)
def _on_change(self):
self.changes += 1
def audit_entries(self) -> list[dict]:
return [json.loads(line) for line in self.audit_path.read_text().splitlines()]
class ClientAdminTest(AdminTestCase):
def test_create_returns_token_once_and_stores_hash(self):
client, token = self.admin.create("team-a", actor="test", max_concurrent=4, note="Team A")
self.assertTrue(token.startswith("mcpc_"))
self.assertEqual(client["token_hint"], token[:9])
stored = self.file.read()["team-a"]
self.assertEqual(stored.token_sha256, hash_token(token))
self.assertNotIn(token, (self.dir / "clients.json").read_text())
self.assertEqual(self.changes, 1)
def test_network_client_has_no_token(self):
client, token = self.admin.create("batch", actor="test", auth="network", allowed_networks=["10.0.0.1"])
self.assertIsNone(token)
self.assertEqual(client["auth"], "network")
def test_lifecycle(self):
_, first = self.admin.create("team-a", actor="test")
self.assertEqual(self.admin.update("team-a", {"enabled": False}, actor="test")["status"], "disabled")
_, second = self.admin.rotate("team-a", actor="test")
self.assertNotEqual(first, second)
self.assertEqual(self.file.read()["team-a"].token_sha256, hash_token(second))
self.assertFalse(self.file.read()["team-a"].enabled, "rotate keeps the other settings")
self.admin.delete("team-a", actor="test")
self.assertEqual(self.admin.list(), [])
def test_errors(self):
self.admin.create("team-a", actor="test")
with self.assertRaises(Conflict):
self.admin.create("team-a", actor="test")
with self.assertRaises(NotFound):
self.admin.update("nobody", {"enabled": False}, actor="test")
with self.assertRaises(ClientConfigError):
self.admin.create("Bad Name", actor="test")
with self.assertRaises(ClientConfigError):
self.admin.update("team-a", {"token_sha256": "0" * 64}, actor="test")
def test_audit_log_has_no_secrets(self):
_, token = self.admin.create("team-a", actor="admin:ops", max_concurrent=2)
self.admin.update("team-a", {"enabled": False}, actor="admin:ops")
self.admin.rotate("team-a", actor="admin:ops")
entries = self.audit_entries()
self.assertEqual([e["action"] for e in entries], ["create", "update", "rotate"])
self.assertTrue(all(e["actor"] == "admin:ops" and e["client"] == "team-a" for e in entries))
text = self.audit_path.read_text()
self.assertNotIn(token, text)
self.assertNotIn(hash_token(token), text)
class AdminApiTest(AdminTestCase, unittest.IsolatedAsyncioTestCase):
def setUp(self):
super().setUp()
self.app = build_admin_app(self.admin, [f"ops:{ADMIN_TOKEN}"])
self.auth = {"Authorization": f"Bearer {ADMIN_TOKEN}"}
async def test_requires_admin_token(self):
self.assertEqual((await asgi_request(self.app, "GET", "/admin/clients"))[0], 401)
self.assertEqual((await asgi_request(self.app, "GET", "/admin/clients", headers={"Authorization": "Bearer wrong"}))[0], 401)
self.assertEqual((await asgi_request(self.app, "GET", "/healthz"))[0], 200)
async def test_crud(self):
status, body = await asgi_request(self.app, "POST", "/admin/clients", headers=self.auth, body={"name": "team-a", "max_concurrent": 3})
self.assertEqual(status, 201)
self.assertTrue(body["token"].startswith("mcpc_"))
status, body = await asgi_request(self.app, "GET", "/admin/clients", headers=self.auth)
self.assertEqual([c["name"] for c in body["clients"]], ["team-a"])
self.assertNotIn("token_sha256", body["clients"][0])
status, body = await asgi_request(self.app, "PATCH", "/admin/clients/team-a", headers=self.auth, body={"enabled": False})
self.assertEqual((status, body["status"]), (200, "disabled"))
status, body = await asgi_request(self.app, "POST", "/admin/clients/team-a/rotate", headers=self.auth)
self.assertEqual(status, 200)
self.assertIn("token", body)
self.assertEqual((await asgi_request(self.app, "DELETE", "/admin/clients/team-a", headers=self.auth))[0], 200)
self.assertEqual((await asgi_request(self.app, "GET", "/admin/clients/team-a", headers=self.auth))[0], 404)
self.assertEqual(self.audit_entries()[0]["actor"], "admin:ops")
async def test_validation_errors(self):
cases = [
("POST", "/admin/clients", {"max_concurrent": 1}, 400), # no name
("POST", "/admin/clients", {"name": "a", "role": "root"}, 400), # unknown field
("POST", "/admin/clients", {"name": "a", "expires": "someday"}, 400),
("PATCH", "/admin/clients/missing", {"enabled": False}, 404),
]
for method, path, body, expected in cases:
with self.subTest(body=body):
self.assertEqual((await asgi_request(self.app, method, path, headers=self.auth, body=body))[0], expected)
await asgi_request(self.app, "POST", "/admin/clients", headers=self.auth, body={"name": "dup"})
self.assertEqual((await asgi_request(self.app, "POST", "/admin/clients", headers=self.auth, body={"name": "dup"}))[0], 409)
class AdminCliTest(AdminTestCase):
def cli(self, *args: str) -> tuple[int, str]:
out = io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
code = admin_cli.main(["--file", str(self.file.path), *args])
return code, out.getvalue()
def test_create_list_and_manage(self):
code, out = self.cli("create", "team-a", "--max-concurrent", "4", "--expires", "2030-12-31")
self.assertEqual(code, 0)
self.assertIn("TOKEN (shown only once", out)
code, out = self.cli("list")
self.assertIn("team-a", out)
self.assertIn("active", out)
self.assertEqual(self.cli("disable", "team-a")[0], 0)
self.assertFalse(self.file.read()["team-a"].enabled)
self.assertEqual(self.cli("enable", "team-a")[0], 0)
self.assertTrue(self.file.read()["team-a"].enabled)
self.assertEqual(self.cli("set", "team-a", "--max-concurrent", "default", "--networks", "10.0.0.0/8", "--expires", "never")[0], 0)
client = self.file.read()["team-a"]
self.assertIsNone(client.max_concurrent)
self.assertIsNone(client.expires)
self.assertEqual([str(n) for n in client.allowed_networks], ["10.0.0.0/8"])
code, out = self.cli("--json", "rotate", "team-a")
self.assertTrue(json.loads(out)["token"].startswith("mcpc_"))
self.assertEqual(self.cli("delete", "team-a", "--yes")[0], 0)
self.assertEqual(self.file.read(), {})
self.assertTrue(all(e["actor"].startswith("cli:") for e in self.audit_entries()))
def test_network_only_client(self):
self.assertEqual(self.cli("create", "batch", "--network-only", "--networks", "10.131.2.15")[0], 0)
self.assertEqual(self.file.read()["batch"].auth, "network")
self.assertEqual(self.cli("create", "nonet", "--network-only")[0], 1)
def test_errors_exit_non_zero(self):
self.assertEqual(self.cli("disable", "missing")[0], 1)
self.assertEqual(self.cli("set", "missing", "--note", "x")[0], 1)
self.cli("create", "team-a")
code, out = self.cli("set", "team-a")
self.assertEqual(code, 1)
self.assertIn("nothing to change", out)

120
tests/test_algebra.py Normal file
View file

@ -0,0 +1,120 @@
from mcp_calculator.ops.algebra import evaluate, solve, transform
from mcp_calculator.ops.context import MathError
from tests.support import MathTestCase
class EvaluateTest(MathTestCase):
def test_exact_and_numeric(self):
r = evaluate("sqrt(8) + 1/3", precision=20)
self.assertEqual(r["exact"], "1/3 + 2*sqrt(2)")
self.assertTrue(r["numeric"].startswith("3.161760458"))
self.assertEqual(len(r["numeric"].replace(".", "")), 20)
def test_decimal_arithmetic_is_exact(self):
self.assertEqual(evaluate("0.1 + 0.2")["exact"], "3/10")
def test_big_integer(self):
r = evaluate("2^200")
self.assertEqual(r["exact"], str(2**200))
self.assertEqual(r["digits"], 61)
def test_variables(self):
self.assertEqual(evaluate("a*x^2 + b", variables={"a": 2, "x": "3", "b": 0.5})["exact"], "37/2")
def test_free_symbols_are_reported(self):
r = evaluate("x + pi")
self.assertEqual(r["free_symbols"], ["x"])
self.assertIn("note", r)
def test_compound_interest(self):
self.assertEqual(evaluate("1000 * (1 + 0.05)^10", precision=12)["numeric"], "1628.89462678")
def test_precision_limit(self):
with self.assertRaises(MathError):
evaluate("pi", precision=5000)
def test_complex(self):
self.assertEqual(evaluate("sqrt(-4)")["exact"], "2*I")
class TransformTest(MathTestCase):
def test_operations(self):
cases = [
("sin(x)^2 + cos(x)^2", "simplify", "1"),
("(x + 1)^2", "expand", "x**2 + 2*x + 1"),
("x^2 - 5x + 6", "factor", "(x - 3)*(x - 2)"),
("(x^2 - 1)/(x - 1)", "cancel", "x + 1"),
("1/(x^2 - 1)", "apart", "-1/(2*(x + 1)) + 1/(2*(x - 1))"),
("1/sqrt(2)", "rationalize_denominator", "sqrt(2)/2"),
("1.4142135623730951", "nsimplify", "sqrt(2)"),
]
for expression, operation, expected in cases:
with self.subTest(operation=operation):
self.assertEqual(transform(expression, operation)["result"], expected)
def test_collect(self):
self.assertEqual(transform("a*x + b*x + c", "collect", variable="x")["result"], "c + x*(a + b)")
def test_substitute(self):
self.assertEqual(transform("x^2 + y", "substitute", substitutions={"x": "2t", "y": 1})["result"], "4*t**2 + 1")
def test_assumption_positive(self):
self.assertEqual(transform("sqrt(x^2)", assume="positive")["result"], "x")
self.assertEqual(transform("sqrt(x^2)")["result"], "sqrt(x**2)")
def test_equivalence_flag(self):
self.assertIs(transform("(x+1)^2", "expand")["equivalent_to_input"], True)
def test_collect_needs_variable(self):
with self.assertRaisesRegex(MathError, "variable"):
transform("x + x*y", "collect")
class SolveTest(MathTestCase):
def test_quadratic(self):
self.assertEqual(solve(["x^2 - 5x + 6 = 0"])["solutions"], [{"x": "2"}, {"x": "3"}])
def test_bare_expression_means_zero(self):
self.assertEqual(solve(["x^2 - 4"])["count"], 2)
def test_complex_vs_real(self):
self.assertEqual(solve(["x^2 = -1"])["solutions"], [{"x": "-I"}, {"x": "I"}])
self.assertEqual(solve(["x^2 = -1"], domain="real")["count"], 0)
def test_linear_system(self):
self.assertEqual(solve(["x + y = 10", "x - y = 2"])["solutions"], [{"x": "6", "y": "4"}])
def test_underdetermined_system(self):
self.assertIn("Infinitely many", solve(["x + y = 10"], variables=["x", "y"])["note"])
def test_nonlinear_system(self):
r = solve(["x^2 + y^2 = 25", "x - y = 1"])
self.assertEqual({tuple(s.items()) for s in r["solutions"]}, {(("x", "-3"), ("y", "-4")), (("x", "4"), ("y", "3"))})
def test_periodic_solutions(self):
r = solve(["sin(x) = 1/2"], domain="real")
self.assertIn("ImageSet", r["solution_set"])
self.assertIn({"x": "pi/6"}, r["principal_solutions"])
def test_inequality(self):
self.assertEqual(solve(["x^2 < 4"])["solution_set"], "Interval.open(-2, 2)")
def test_inequality_system(self):
self.assertEqual(solve(["x > 1", "x <= 5"])["solution_set"], "Interval.Lopen(1, 5)")
def test_numeric_transcendental(self):
self.assertTrue(solve(["cos(x) = x"], numeric_only=True)["solutions"][0]["x"].startswith("0.7390851332"))
def test_numeric_polynomial_all_roots(self):
self.assertEqual(solve(["x^5 - x + 1"], numeric_only=True)["count"], 5)
def test_numeric_system_with_guess(self):
r = solve(["x^2 + y^2 = 4", "x = y"], numeric_only=True, initial_guess=[1, 1])
self.assertTrue(r["solutions"][0]["x"].startswith("1.414213562"))
def test_positive_domain(self):
self.assertEqual(solve(["x^2 = 4"], domain="positive")["solutions"], [{"x": "2"}])
def test_no_variable(self):
with self.assertRaises(MathError):
solve(["1 = 1"])

117
tests/test_auth.py Normal file
View file

@ -0,0 +1,117 @@
"""Authentication decisions and the per-client concurrency limit."""
import asyncio
import unittest
from mcp_calculator.auth import LOCAL, Failure, Identity, authenticate, extract_token
from mcp_calculator.clients import Client, Snapshot, hash_token
from mcp_calculator.executor import ComputationError
from mcp_calculator.ratelimit import ClientConcurrencyLimiter
TOKEN_A = "mcpc_team-a-token-000000000000000000000000"
TOKEN_SVC = "mcpc_service-token-00000000000000000000000"
TOKEN_OFF = "mcpc_disabled-token-0000000000000000000000"
TOKEN_OLD = "mcpc_expired-token-00000000000000000000000"
def snapshot() -> Snapshot:
clients = {
"team-a": Client.from_dict("team-a", {"token_sha256": hash_token(TOKEN_A), "max_concurrent": 3}),
"reporting": Client.from_dict("reporting", {"token_sha256": hash_token(TOKEN_SVC), "allowed_networks": ["10.130.4.0/24"]}),
"off": Client.from_dict("off", {"token_sha256": hash_token(TOKEN_OFF), "enabled": False}),
"old": Client.from_dict("old", {"token_sha256": hash_token(TOKEN_OLD), "expires": "2000-01-01"}),
"batch-net": Client.from_dict("batch-net", {"auth": "network", "allowed_networks": ["10.131.0.0/16"]}),
"batch-host": Client.from_dict("batch-host", {"auth": "network", "allowed_networks": ["10.131.2.15/32"], "max_concurrent": 1}),
}
return Snapshot.build(clients)
class AuthenticateTest(unittest.TestCase):
def setUp(self):
self.snapshot = snapshot()
def test_valid_token(self):
self.assertEqual(authenticate(self.snapshot, TOKEN_A, "203.0.113.1"), Identity("team-a", "token", 3))
def test_failures(self):
cases = [
(None, "203.0.113.1", "missing_credentials"),
("mcpc_unknown", "203.0.113.1", "invalid_token"),
(TOKEN_OFF, "203.0.113.1", "disabled"),
(TOKEN_OLD, "203.0.113.1", "expired"),
(TOKEN_SVC, "10.130.5.1", "network_denied"),
(TOKEN_SVC, None, "network_denied"),
]
for token, ip, reason in cases:
with self.subTest(reason=reason):
result = authenticate(self.snapshot, token, ip)
self.assertIsInstance(result, Failure)
self.assertEqual(result.reason, reason)
def test_http_status_codes(self):
self.assertEqual(Failure("missing_credentials").status, 401)
self.assertEqual(Failure("invalid_token").status, 401)
self.assertEqual(Failure("disabled").status, 403)
self.assertEqual(Failure("network_denied").status, 403)
def test_token_restricted_to_network(self):
self.assertEqual(authenticate(self.snapshot, TOKEN_SVC, "10.130.4.77").name, "reporting")
def test_network_client_without_token(self):
self.assertEqual(authenticate(self.snapshot, None, "10.131.9.9"), Identity("batch-net", "network", None))
def test_most_specific_network_wins(self):
self.assertEqual(authenticate(self.snapshot, None, "10.131.2.15"), Identity("batch-host", "network", 1))
def test_invalid_token_is_rejected_even_from_trusted_network(self):
self.assertEqual(authenticate(self.snapshot, "mcpc_wrong", "10.131.2.15").reason, "invalid_token")
class ExtractTokenTest(unittest.TestCase):
def test_sources(self):
self.assertEqual(extract_token([(b"authorization", b"Bearer abc")]), "abc")
self.assertEqual(extract_token([(b"authorization", b"bearer abc ")]), "abc")
self.assertEqual(extract_token([(b"x-api-key", b"xyz")]), "xyz")
self.assertIsNone(extract_token([(b"authorization", b"Basic dXNlcjpwYXNz")]))
self.assertIsNone(extract_token([]))
class ClientConcurrencyTest(unittest.IsolatedAsyncioTestCase):
async def test_limit_per_client(self):
limiter = ClientConcurrencyLimiter(default_limit=2)
client = Identity("team-a", "token", None)
async with limiter.slot(client), limiter.slot(client):
self.assertEqual(limiter.active("team-a"), 2)
with self.assertRaisesRegex(ComputationError, "client busy") as ctx:
async with limiter.slot(client):
pass
self.assertEqual(ctx.exception.kind, "busy")
# other clients are not affected
async with limiter.slot(Identity("team-b", "token", None)):
pass
self.assertEqual(limiter.active("team-a"), 0)
async def test_client_specific_limit_and_unlimited(self):
limiter = ClientConcurrencyLimiter(default_limit=1)
self.assertEqual(limiter.limit_for(Identity("x", "token", 5)), 5)
self.assertEqual(limiter.limit_for(Identity("x", "token", 0)), 0)
self.assertEqual(limiter.limit_for(LOCAL), 0, "stdio is never limited")
async with limiter.slot(Identity("x", "token", 0)), limiter.slot(Identity("x", "token", 0)):
pass
async def test_slot_is_released_on_error_and_cancel(self):
limiter = ClientConcurrencyLimiter(default_limit=1)
client = Identity("team-a", "token", None)
with self.assertRaises(ValueError):
async with limiter.slot(client):
raise ValueError
task = asyncio.create_task(self._hold(limiter, client))
await asyncio.sleep(0.01)
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
self.assertEqual(limiter.active("team-a"), 0)
async def _hold(self, limiter, client):
async with limiter.slot(client):
await asyncio.sleep(10)

104
tests/test_calculus.py Normal file
View file

@ -0,0 +1,104 @@
from mcp_calculator.ops.calculus import analyze_function, calculus, solve_ode
from mcp_calculator.ops.context import MathError
from tests.support import MathTestCase
class CalculusTest(MathTestCase):
def test_derivative(self):
self.assertEqual(calculus("derivative", "x^3")["result"], "3*x**2")
def test_higher_derivative(self):
self.assertEqual(calculus("derivative", "x^5", order=3)["result"], "60*x**2")
def test_mixed_partial(self):
self.assertEqual(calculus("derivative", "x^2*y^3", variable="x, y")["result"], "6*x*y**2")
def test_indefinite_integral(self):
r = calculus("integral", "x*e^x")
self.assertEqual(r["result"], "(x - 1)*exp(x)")
self.assertIn("constant", r["note"])
def test_definite_integral(self):
self.assertEqual(calculus("integral", "e^(-x^2)", lower="-oo", upper="oo")["result"], "sqrt(pi)")
def test_numeric_integral_fallback(self):
r = calculus("integral", "sin(sin(x))", lower="0", upper="1")
self.assertTrue(r["method"].startswith("numeric"))
self.assertTrue(r["numeric"].startswith("0.4306061031"))
def test_integral_needs_both_bounds(self):
with self.assertRaises(MathError):
calculus("integral", "x", lower="0")
def test_limit(self):
self.assertEqual(calculus("limit", "sin(x)/x", point="0")["result"], "1")
self.assertEqual(calculus("limit", "(1 + 1/n)^n", variable="n", point="oo")["result"], "E")
def test_one_sided_limit(self):
self.assertEqual(calculus("limit", "1/x", point="0", direction="+")["result"], "oo")
self.assertEqual(calculus("limit", "1/x", point="0", direction="-")["result"], "-oo")
def test_series(self):
self.assertEqual(calculus("series", "exp(x)", terms=4)["polynomial"], "x**3/6 + x**2/2 + x + 1")
def test_infinite_sum(self):
r = calculus("sum", "1/n^2", variable="n", lower="1", upper="oo")
self.assertEqual(r["result"], "pi**2/6")
self.assertIs(r["converges"], True)
def test_finite_sum_and_product(self):
self.assertEqual(calculus("sum", "k", variable="k", lower="1", upper="100")["result"], "5050")
self.assertEqual(calculus("product", "k", variable="k", lower="1", upper="5")["result"], "120")
def test_gradient_hessian_jacobian(self):
self.assertEqual(calculus("gradient", "x^2*y", variable="x,y")["result"], ["2*x*y", "x**2"])
self.assertEqual(calculus("hessian", "x^2*y", variable="x,y")["result"], [["2*y", "2*x"], ["2*x", "0"]])
self.assertEqual(calculus("jacobian", "x*y, x + y", variable="x,y")["result"], [["y", "x"], ["1", "1"]])
class AnalyzeFunctionTest(MathTestCase):
def test_cubic(self):
r = analyze_function("x^3 - 3x")
self.assertEqual({z["x"] for z in r["zeros"]}, {"0", "-sqrt(3)", "sqrt(3)"})
self.assertEqual({(e["x"], e["type"]) for e in r["extrema"]}, {("-1", "maximum"), ("1", "minimum")})
self.assertEqual(r["inflection_points"][0]["x"], "0")
self.assertTrue(r["symmetry"].startswith("odd"))
self.assertEqual(r["limit_at_plus_infinity"], "oo")
def test_rational_function(self):
r = analyze_function("1/(x^2 - 1)")
self.assertEqual(r["vertical_asymptotes"], ["-1", "1"])
self.assertTrue(r["symmetry"].startswith("even"))
self.assertEqual(r["limit_at_plus_infinity"], "0")
def test_saddle_point(self):
self.assertTrue(analyze_function("x^3")["extrema"][0]["type"].startswith("saddle"))
def test_rejects_parameters(self):
with self.assertRaisesRegex(MathError, "parameter"):
analyze_function("a*x^2")
class OdeTest(MathTestCase):
def test_general_solution(self):
r = solve_ode("y'' + y = 0")
self.assertEqual(r["solutions"], ["C1*sin(x) + C2*cos(x)"])
self.assertIn("C1", r["note"])
def test_initial_value_problem(self):
self.assertEqual(solve_ode("y' = 2y", initial_conditions={"y(0)": 3})["solutions"], ["3*exp(2*x)"])
def test_second_order_ivp(self):
self.assertEqual(solve_ode("y'' + y = 0", initial_conditions={"y(0)": 0, "y'(0)": 1})["solutions"], ["sin(x)"])
def test_custom_names(self):
r = solve_ode("N' = -k*N", function="N", variable="t", initial_conditions={"N(0)": "N0"})
self.assertEqual(r["solutions"], ["N0*exp(-k*t)"])
def test_requires_derivative(self):
with self.assertRaisesRegex(MathError, "no derivative"):
solve_ode("y = x")
def test_bad_initial_condition_key(self):
with self.assertRaisesRegex(MathError, "initial condition"):
solve_ode("y' = y", initial_conditions={"z(0)": 1})

192
tests/test_clients.py Normal file
View file

@ -0,0 +1,192 @@
"""Client model, clients file and hot-reloading registry."""
import json
import os
import stat
import tempfile
import time
import unittest
from datetime import date, timedelta
from pathlib import Path
from mcp_calculator.clients import (
Client,
ClientConfigError,
ClientFile,
ClientRegistry,
clients_from_env,
hash_token,
new_token,
)
HASH = "a" * 64
class ClientModelTest(unittest.TestCase):
def test_token_client(self):
c = Client.from_dict("team-a", {"token_sha256": HASH, "max_concurrent": 3, "allowed_networks": ["10.0.0.0/8"]})
self.assertEqual(c.status(), "active")
self.assertEqual(c.max_concurrent, 3)
self.assertTrue(c.allows_ip("10.1.2.3"))
self.assertFalse(c.allows_ip("192.168.1.1"))
self.assertFalse(c.allows_ip(None))
def test_without_networks_every_ip_is_allowed(self):
self.assertTrue(Client.from_dict("a", {"token_sha256": HASH}).allows_ip("203.0.113.9"))
def test_status(self):
today = date(2027, 1, 10)
self.assertEqual(Client.from_dict("a", {"token_sha256": HASH, "enabled": False}).status(today), "disabled")
self.assertEqual(Client.from_dict("a", {"token_sha256": HASH, "expires": "2027-01-09"}).status(today), "expired")
self.assertEqual(Client.from_dict("a", {"token_sha256": HASH, "expires": "2027-01-10"}).status(today), "active")
def test_network_client(self):
c = Client.from_dict("batch", {"auth": "network", "allowed_networks": ["10.131.2.15/32"]})
self.assertEqual(c.auth, "network")
self.assertTrue(c.allows_ip("10.131.2.15"))
def test_invalid_entries(self):
cases = {
"bad name": ("Team A", {"token_sha256": HASH}),
"missing hash": ("a", {}),
"short hash": ("a", {"token_sha256": "abc"}),
"unknown field": ("a", {"token_sha256": HASH, "admin": True}),
"bad auth": ("a", {"auth": "magic", "token_sha256": HASH}),
"network without networks": ("a", {"auth": "network"}),
"network with token": ("a", {"auth": "network", "token_sha256": HASH, "allowed_networks": ["10.0.0.1"]}),
"bad network": ("a", {"token_sha256": HASH, "allowed_networks": ["10.0.0.300/8"]}),
"bad date": ("a", {"token_sha256": HASH, "expires": "tomorrow"}),
"negative limit": ("a", {"token_sha256": HASH, "max_concurrent": -1}),
"enabled as string": ("a", {"token_sha256": HASH, "enabled": "yes"}),
}
for label, (name, data) in cases.items():
with self.subTest(label), self.assertRaises(ClientConfigError):
Client.from_dict(name, data)
def test_with_changes(self):
c = Client.from_dict("a", {"token_sha256": HASH})
changed = c.with_changes({"enabled": False, "max_concurrent": 5, "expires": "2030-01-01", "allowed_networks": ["10.0.0.0/8"]})
self.assertFalse(changed.enabled)
self.assertEqual(changed.max_concurrent, 5)
self.assertEqual(changed.expires, date(2030, 1, 1))
self.assertIsNotNone(changed.updated)
with self.assertRaisesRegex(ClientConfigError, "cannot change"):
c.with_changes({"token_sha256": "b" * 64})
def test_public_view_hides_hash(self):
view = Client.from_dict("a", {"token_sha256": HASH, "token_hint": "mcpc_abcd"}).public_view()
self.assertNotIn("token_sha256", view)
self.assertEqual(view["token_hint"], "mcpc_abcd")
self.assertEqual(view["status"], "active")
def test_tokens(self):
token = new_token()
self.assertTrue(token.startswith("mcpc_"))
self.assertGreaterEqual(len(token), 40)
self.assertNotEqual(token, new_token())
self.assertEqual(len(hash_token(token)), 64)
def test_clients_from_env(self):
clients = clients_from_env(["svc:0123456789abcdef"])
self.assertEqual(clients["svc"].token_sha256, hash_token("0123456789abcdef"))
self.assertEqual(clients["svc"].source, "env")
class ClientFileTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.path = Path(self.tmp.name) / "data" / "clients.json"
self.file = ClientFile(self.path)
def test_missing_file_is_empty(self):
self.assertEqual(self.file.read(), {})
def test_update_roundtrip(self):
with self.file.update() as clients:
clients["a"] = Client.from_dict("a", {"token_sha256": HASH, "note": "Team A"})
raw = json.loads(self.path.read_text())
self.assertEqual(raw["version"], 1)
self.assertEqual(raw["clients"]["a"]["note"], "Team A")
self.assertEqual(self.file.read()["a"].token_sha256, HASH)
self.assertEqual(stat.S_IMODE(os.stat(self.path).st_mode), 0o640)
self.assertEqual([p.name for p in self.path.parent.iterdir() if p.suffix == ".tmp"], [])
def test_failed_update_leaves_file_untouched(self):
with self.file.update() as clients:
clients["a"] = Client.from_dict("a", {"token_sha256": HASH})
before = self.path.read_text()
with self.assertRaises(RuntimeError), self.file.update() as clients:
clients.clear()
raise RuntimeError("abort")
self.assertEqual(self.path.read_text(), before)
def test_invalid_json(self):
self.path.parent.mkdir(parents=True)
self.path.write_text("{not json")
with self.assertRaisesRegex(ClientConfigError, "invalid JSON"):
self.file.read()
def test_duplicate_tokens_rejected(self):
self.path.parent.mkdir(parents=True)
self.path.write_text(json.dumps({"clients": {"a": {"token_sha256": HASH}, "b": {"token_sha256": HASH}}}))
with self.assertRaisesRegex(ClientConfigError, "same token"):
self.file.read()
class RegistryTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.file = ClientFile(Path(self.tmp.name) / "clients.json")
def _write(self, clients: dict) -> None:
with self.file.update() as current:
current.clear()
current.update({n: Client.from_dict(n, d) for n, d in clients.items()})
# make sure the fingerprint changes even on file systems with coarse timestamps
future = time.time() + 1 + len(clients)
os.utime(self.file.path, (future, future))
def test_hot_reload(self):
self._write({"a": {"token_sha256": HASH}})
registry = ClientRegistry(self.file, {}, reload_seconds=0.01)
self.assertEqual(set(registry.snapshot().clients), {"a"})
self._write({"a": {"token_sha256": HASH}, "b": {"token_sha256": "b" * 64}})
time.sleep(0.02)
self.assertEqual(set(registry.snapshot().clients), {"a", "b"})
self.assertIn("b" * 64, registry.snapshot().by_hash)
def test_invalid_change_keeps_previous_state(self):
self._write({"a": {"token_sha256": HASH}})
registry = ClientRegistry(self.file, {}, reload_seconds=0.01)
self.file.path.write_text("{broken")
os.utime(self.file.path, (time.time() + 5, time.time() + 5))
time.sleep(0.02)
with self.assertLogs("mcp_calculator.clients", "ERROR"):
snapshot = registry.snapshot()
self.assertEqual(set(snapshot.clients), {"a"})
def test_invalid_file_at_startup_fails(self):
self.file.path.write_text("{broken")
with self.assertRaises(ClientConfigError):
ClientRegistry(self.file, {})
def test_static_and_file_clients_are_merged(self):
self._write({"a": {"token_sha256": HASH}})
registry = ClientRegistry(self.file, clients_from_env(["svc:0123456789abcdef"]))
self.assertEqual(set(registry.snapshot().clients), {"a", "svc"})
def test_name_clash_between_sources_fails(self):
self._write({"svc": {"token_sha256": HASH}})
with self.assertRaisesRegex(ClientConfigError, "both"):
ClientRegistry(self.file, clients_from_env(["svc:0123456789abcdef"]))
def test_disabled_without_sources(self):
self.assertFalse(ClientRegistry(None, {}).enabled)
self.assertTrue(ClientRegistry(self.file, {}).enabled)
def test_expiry_is_evaluated_at_request_time(self):
yesterday = (date.today() - timedelta(days=1)).isoformat()
self._write({"old": {"token_sha256": HASH, "expires": yesterday}})
self.assertEqual(ClientRegistry(self.file, {}).snapshot().clients["old"].status(), "expired")

61
tests/test_config.py Normal file
View file

@ -0,0 +1,61 @@
import unittest
from mcp_calculator.config import load_settings
TOKEN = "0123456789abcdef"
class SettingsTest(unittest.TestCase):
def test_defaults(self):
s = load_settings([], env={})
self.assertEqual(s.transport, "stdio")
self.assertEqual(s.executor, "process")
self.assertEqual(s.auth_tokens, [])
self.assertEqual(s.clients_file, "")
self.assertEqual(s.admin_tokens, [])
self.assertTrue(s.metrics)
self.assertEqual(s.max_queue, 20)
self.assertEqual(s.max_queue_wait_seconds, 30)
self.assertEqual(s.client_max_concurrent, 10)
self.assertEqual(s.trusted_proxies, [])
def test_env_and_cli_precedence(self):
env = {"MCP_CALC_TRANSPORT": "http", "MCP_CALC_PORT": "9000", "MCP_CALC_AUTH_TOKENS": f"a:{TOKEN}, b:{TOKEN}x"}
s = load_settings(["--port", "9100"], env=env)
self.assertEqual(s.transport, "http")
self.assertEqual(s.port, 9100)
self.assertEqual(s.auth_tokens, [f"a:{TOKEN}", f"b:{TOKEN}x"])
def test_limits_from_env(self):
s = load_settings([], env={"MCP_CALC_MAX_MATRIX_SIZE": "5", "MCP_CALC_TIMEOUT_SECONDS": "2.5"})
self.assertEqual(s.limits.max_matrix_size, 5)
self.assertEqual(s.timeout_seconds, 2.5)
def test_booleans(self):
self.assertFalse(load_settings([], env={"MCP_CALC_METRICS": "false"}).metrics)
self.assertTrue(load_settings([], env={"MCP_CALC_METRICS": "On"}).metrics)
def test_admin_settings(self):
s = load_settings([], env={"MCP_CALC_CLIENTS_FILE": "/data/clients.json", "MCP_CALC_ADMIN_TOKENS": f"ops:{TOKEN}"})
self.assertEqual(s.admin_tokens, [f"ops:{TOKEN}"])
self.assertEqual(s.admin_port, 9090)
self.assertEqual(s.admin_host, "127.0.0.1")
def test_invalid_settings(self):
for env in (
{"MCP_CALC_TRANSPORT": "websocket"},
{"MCP_CALC_PORT": "abc"},
{"MCP_CALC_WORKERS": "0"},
{"MCP_CALC_MAX_QUEUE": "-1"},
{"MCP_CALC_MAX_QUEUE_WAIT_SECONDS": "0"},
{"MCP_CALC_CLIENT_MAX_CONCURRENT": "-1"},
{"MCP_CALC_MAX_CONNECTIONS": "0"},
{"MCP_CALC_METRICS": "maybe"},
{"MCP_CALC_PATH": "mcp"},
{"MCP_CALC_AUTH_TOKENS": "no-name-token-0123456789"},
{"MCP_CALC_AUTH_TOKENS": "name:short"},
{"MCP_CALC_ADMIN_TOKENS": f"ops:{TOKEN}"}, # admin API without clients file
{"MCP_CALC_CLIENTS_FILE": "/x.json", "MCP_CALC_ADMIN_TOKENS": f"ops:{TOKEN}", "MCP_CALC_ADMIN_PORT": "8080"},
):
with self.subTest(env=env), self.assertRaises(SystemExit):
load_settings([], env=env)

113
tests/test_footprint.py Normal file
View file

@ -0,0 +1,113 @@
"""Guards for the memory layout described in docs/memory.md.
* The server process must not import the math libraries (they live in the workers).
* Workers must not import the MCP/HTTP stack (spawn re-imports the main module).
"""
from __future__ import annotations
import builtins
import io
import json
import os
import subprocess
import sys
import tempfile
import textwrap
import typing
import unittest
from pathlib import Path
from unittest import mock
from mcp_calculator import executor as executor_module
from mcp_calculator.config import load_settings
from mcp_calculator.executor import ComputationError, Executor
from mcp_calculator.ops import spec, stats
from tests.support import slow
MATH_LIBS = ("sympy", "numpy", "scipy", "pint", "mpmath")
SERVER_LIBS = ("mcp", "uvicorn", "starlette", "pydantic", "anyio")
def _run_python(code: str, modules: tuple[str, ...]) -> dict:
"""Run ``code`` as a script (MODULES is replaced by the module names); return its last JSON line."""
with tempfile.TemporaryDirectory() as tmp:
script = Path(tmp) / "probe.py"
script.write_text(textwrap.dedent(code).replace("MODULES", repr(modules)))
out = subprocess.run([sys.executable, str(script)], capture_output=True, text=True, timeout=120, check=True)
return json.loads(out.stdout.strip().splitlines()[-1])
@slow
class ImportFootprintTest(unittest.TestCase):
def test_server_process_does_not_import_math_libraries(self):
loaded = _run_python(
"""
import json, sys
import mcp_calculator.__main__, mcp_calculator.server, mcp_calculator.http_app, mcp_calculator.executor
print(json.dumps({m: m in sys.modules for m in MODULES}))
""",
MATH_LIBS,
)
self.assertFalse(any(loaded.values()), loaded)
def test_workers_do_not_import_server_stack(self):
# Mirrors the console script: the main module imports mcp_calculator.__main__.
loaded = _run_python(
"""
import json, sys
from mcp_calculator.__main__ import main # noqa: F401
def probe():
import os
loaded = {m: m in sys.modules for m in MODULES}
loaded["single_threaded_blas"] = os.environ.get("OPENBLAS_NUM_THREADS") == "1"
return loaded
if __name__ == "__main__":
from mcp_calculator.config import load_settings
from mcp_calculator.executor import Executor
executor = Executor(load_settings([], env={"MCP_CALC_WORKERS": "1"}))
executor.start()
result = executor._pool.schedule(probe, timeout=60).result()
executor.stop()
print(json.dumps(result))
""",
MATH_LIBS + SERVER_LIBS,
)
self.assertTrue(loaded["sympy"] and loaded["scipy"], "workers should preload the math libraries")
self.assertFalse(any(loaded[m] for m in ("mcp", "uvicorn", "starlette")), loaded)
self.assertTrue(loaded["single_threaded_blas"])
class MemoryLimitTest(unittest.TestCase):
def test_limit_is_relative_to_loaded_size(self):
"""RLIMIT_AS = current address space + configured extra (Linux path, simulated)."""
with (
mock.patch.object(builtins, "open", lambda *a, **k: io.StringIO("100000 5000 0 0 0 0 0")),
mock.patch.object(executor_module.resource, "setrlimit") as setrlimit,
):
executor_module._limit_address_space(512)
expected = 100000 * os.sysconf("SC_PAGE_SIZE") + 512 * 1024 * 1024
setrlimit.assert_called_once_with(executor_module.resource.RLIMIT_AS, (expected, expected))
def test_failure_is_not_fatal(self):
with mock.patch.object(builtins, "open", side_effect=OSError("no /proc")), self.assertLogs("mcp_calculator.executor", "WARNING") as logs:
executor_module._limit_address_space(512)
self.assertIn("could not set memory limit", logs.output[0])
class TaskDispatchTest(unittest.IsolatedAsyncioTestCase):
async def test_only_public_ops_functions_can_be_called(self):
executor = Executor(load_settings([], env={"MCP_CALC_EXECUTOR": "inline"}))
for task in ("os.system", "algebra._equivalent", "algebra", "parsing.parse", "algebra.__class__"):
with self.subTest(task=task), self.assertRaisesRegex(ComputationError, "unknown task"):
await executor.run(task)
class SpecConsistencyTest(unittest.TestCase):
def test_distribution_tables_are_consistent(self):
names = set(typing.get_args(spec.Distribution))
self.assertEqual(names, set(spec.DISTRIBUTION_PARAMETERS))
self.assertEqual(names, set(stats._DISTRIBUTIONS))

159
tests/test_http_security.py Normal file
View file

@ -0,0 +1,159 @@
"""End-to-end: authentication with a clients file, hot reload, admin API, per-client limit,
cancel on disconnect and the connection limit, against a real server process."""
from __future__ import annotations
import contextlib
import http.client
import io
import json
import re
import socket
import tempfile
import threading
import time
import unittest
from pathlib import Path
from mcp_calculator import admin_cli
from mcp_calculator.admin import AuditLog, ClientAdmin
from mcp_calculator.clients import ClientFile
from tests.support import slow
from tests.test_transports import _free_port, _http, _start_server
ADMIN_TOKEN = "admin-token-0123456789"
def _call(port: int, token: str | None, expression: str = "6*7", *, header: str = "Authorization") -> tuple[int, dict | str]:
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "evaluate", "arguments": {"expression": expression}}}
if header == "Authorization" or token is None:
status, text = _http("POST", f"http://127.0.0.1:{port}/mcp", body, token=token)
else: # X-API-Key
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=30)
conn.request("POST", "/mcp", json.dumps(body), {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", header: token})
response = conn.getresponse()
status, text = response.status, response.read().decode()
conn.close()
try:
return status, json.loads(text)
except json.JSONDecodeError:
return status, text
def _metric(port: int, pattern: str) -> float:
text = _http("GET", f"http://127.0.0.1:{port}/metrics")[1]
match = re.search(pattern + r" ([0-9.e+]+)", text)
return float(match.group(1)) if match else 0.0
def _wait_for(predicate, timeout: float = 5.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(0.1)
return False
@slow
class ClientsFileAuthTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.tmp = tempfile.TemporaryDirectory()
cls.clients_path = Path(cls.tmp.name) / "clients.json"
admin = ClientAdmin(ClientFile(cls.clients_path), AuditLog(Path(cls.tmp.name) / "audit.log"))
_, cls.token = admin.create("team-a", actor="test")
_, cls.single = admin.create("single", actor="test", max_concurrent=1)
cls.port, cls.admin_port = _free_port(), _free_port()
cls.proc = _start_server(
cls.port,
MCP_CALC_CLIENTS_FILE=str(cls.clients_path),
MCP_CALC_CLIENTS_RELOAD_SECONDS="0.2",
MCP_CALC_ADMIN_TOKENS=f"ops:{ADMIN_TOKEN}",
MCP_CALC_ADMIN_PORT=str(cls.admin_port),
MCP_CALC_TIMEOUT_SECONDS="20",
MCP_CALC_LOG_LEVEL="WARNING",
)
@classmethod
def tearDownClass(cls) -> None:
cls.proc.terminate()
cls.proc.communicate(timeout=60)
cls.tmp.cleanup()
def cli(self, *args: str) -> None:
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(admin_cli.main(["--file", str(self.clients_path), *args]), 0)
def admin(self, method: str, path: str, body: dict | None = None, token: str = ADMIN_TOKEN) -> tuple[int, dict]:
status, text = _http(method, f"http://127.0.0.1:{self.admin_port}{path}", body, token=token)
return status, json.loads(text) if text else {}
def test_token_header_variants(self):
status, body = _call(self.port, self.token)
self.assertEqual(status, 200)
self.assertIn("42", body["result"]["content"][0]["text"])
self.assertEqual(_call(self.port, self.token, header="X-API-Key")[0], 200)
self.assertEqual(_call(self.port, None)[0], 401)
self.assertEqual(_call(self.port, "mcpc_wrong")[0], 401)
def test_disable_via_cli_takes_effect_without_restart(self):
_, token = ClientAdmin(ClientFile(self.clients_path), AuditLog(None)).create("temp-cli", actor="test")
self.assertTrue(_wait_for(lambda: _call(self.port, token)[0] == 200))
self.cli("disable", "temp-cli")
self.assertTrue(_wait_for(lambda: _call(self.port, token)[0] == 403), "disabled client must be rejected after reload")
self.cli("enable", "temp-cli")
self.assertTrue(_wait_for(lambda: _call(self.port, token)[0] == 200))
def test_admin_api_over_http(self):
self.assertEqual(self.admin("GET", "/admin/clients", token="wrong")[0], 401)
status, body = self.admin("POST", "/admin/clients", {"name": "temp-api", "max_concurrent": 2})
self.assertEqual(status, 201)
token = body["token"]
self.assertEqual(_call(self.port, token)[0], 200, "the replica serving the admin call applies it immediately")
status, body = self.admin("POST", "/admin/clients/temp-api/rotate")
self.assertEqual(_call(self.port, token)[0], 401, "old token is invalid after rotation")
self.assertEqual(_call(self.port, body["token"])[0], 200)
self.assertEqual(self.admin("DELETE", "/admin/clients/temp-api")[0], 200)
self.assertEqual(_call(self.port, body["token"])[0], 401)
def test_per_client_concurrency_limit(self):
blocker = threading.Thread(target=_call, args=(self.port, self.single, "9^9^9^9"), daemon=True)
blocker.start()
self.assertTrue(_wait_for(lambda: _metric(self.port, r'mcp_calc_client_inflight_computations\{client="single"\}') == 1))
status, body = _call(self.port, self.single)
self.assertEqual(status, 200)
self.assertTrue(body["result"]["isError"])
self.assertIn("client busy", body["result"]["content"][0]["text"])
self.assertEqual(_call(self.port, self.token)[0], 200, "other clients are not affected")
def test_disconnect_cancels_computation(self):
before = _metric(self.port, r'mcp_calc_tool_calls_total\{client="team-a",status="cancelled",tool="evaluate"\}')
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "evaluate", "arguments": {"expression": "9^9^9^9"}}}
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=0.5)
conn.request("POST", "/mcp", json.dumps(body), {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Authorization": f"Bearer {self.token}"})
with self.assertRaises((TimeoutError, socket.timeout)):
conn.getresponse()
conn.close()
pattern = r'mcp_calc_tool_calls_total\{client="team-a",status="cancelled",tool="evaluate"\}'
self.assertTrue(_wait_for(lambda: _metric(self.port, pattern) == before + 1), "computation must be cancelled well before its 20 s timeout")
@slow
class ConnectionLimitTest(unittest.TestCase):
def test_connections_beyond_the_limit_get_503(self):
port = _free_port()
# uvicorn counts the incoming connection itself: with a limit of 2, one long request
# is served and the next concurrent connection is rejected.
proc = _start_server(port, MCP_CALC_MAX_CONNECTIONS="2", MCP_CALC_TIMEOUT_SECONDS="3", MCP_CALC_LOG_LEVEL="WARNING")
try:
blocker = threading.Thread(target=_call, args=(port, None, "9^9^9^9"), daemon=True)
blocker.start()
time.sleep(0.5)
self.assertEqual(_call(port, None)[0], 503)
blocker.join(timeout=10)
self.assertEqual(_call(port, None)[0], 200)
finally:
proc.terminate()
proc.communicate(timeout=30)

74
tests/test_linalg.py Normal file
View file

@ -0,0 +1,74 @@
from mcp_calculator.config import Limits
from mcp_calculator.ops.context import MathError, set_limits
from mcp_calculator.ops.linalg import matrix
from tests.support import MathTestCase
A = [[1, 2], [3, 4]]
class ExactMatrixTest(MathTestCase):
def test_determinant(self):
self.assertEqual(matrix("determinant", A)["result"], "-2")
def test_symbolic_determinant(self):
self.assertEqual(matrix("determinant", [["a", "b"], ["c", "d"]])["result"], "a*d - b*c")
def test_inverse_is_exact(self):
self.assertEqual(matrix("inverse", A)["result"], [["-2", "1"], ["3/2", "-1/2"]])
def test_singular_inverse(self):
with self.assertRaisesRegex(MathError, "singular"):
matrix("inverse", [[1, 2], [2, 4]])
def test_eigenvalues(self):
values = {e["value"] for e in matrix("eigenvalues", [[2, 1], [1, 2]])["result"]}
self.assertEqual(values, {"1", "3"})
def test_eigenvectors(self):
r = matrix("eigenvectors", [[2, 0], [0, 3]])["result"]
self.assertEqual({e["value"]: e["vectors"] for e in r}, {"2": [["1", "0"]], "3": [["0", "1"]]})
def test_solve_linear_system(self):
self.assertEqual(matrix("solve", [[1, 1], [1, -1]], [10, 2])["result"], [["6"], ["4"]])
def test_solve_inconsistent(self):
self.assertIsNone(matrix("solve", [[1, 1], [1, 1]], [1, 2])["result"])
def test_multiply_and_shapes(self):
self.assertEqual(matrix("multiply", A, [[1], [1]])["result"], [["3"], ["7"]])
with self.assertRaisesRegex(MathError, "not compatible"):
matrix("multiply", A, [[1, 2, 3]])
def test_power_rank_trace_transpose(self):
self.assertEqual(matrix("power", A, exponent=2)["result"], [["7", "10"], ["15", "22"]])
self.assertEqual(matrix("rank", [[1, 2], [2, 4]])["result"], 1)
self.assertEqual(matrix("trace", A)["result"], "5")
self.assertEqual(matrix("transpose", A)["result"], [["1", "3"], ["2", "4"]])
def test_vectors(self):
self.assertEqual(matrix("dot", [1, 2, 3], [4, 5, 6])["result"], "32")
self.assertEqual(matrix("cross", [1, 0, 0], [0, 1, 0])["result"], [["0"], ["0"], ["1"]])
def test_characteristic_polynomial(self):
self.assertEqual(matrix("characteristic_polynomial", A)["result"], "lambda**2 - 5*lambda - 2")
class NumericMatrixTest(MathTestCase):
def test_numeric_mode(self):
r = matrix("determinant", A, numeric_mode=True)
self.assertTrue(r["mode"].startswith("numeric"))
self.assertApprox(r["result"], -2)
def test_svd_is_numeric(self):
self.assertEqual(len(matrix("svd", A)["singular_values"]), 2)
class MatrixValidationTest(MathTestCase):
def test_size_limit(self):
set_limits(Limits(max_matrix_size=2))
with self.assertRaisesRegex(MathError, "larger than"):
matrix("determinant", [[1, 2, 3], [4, 5, 6], [7, 8, 9]])
def test_ragged_rows(self):
with self.assertRaisesRegex(MathError, "same length"):
matrix("determinant", [[1, 2], [3]])

View file

@ -0,0 +1,94 @@
from mcp_calculator.ops.context import MathError
from mcp_calculator.ops.number_theory import number_theory
from mcp_calculator.ops.units import convert_units
from tests.support import MathTestCase
class NumberTheoryTest(MathTestCase):
def test_operations(self):
cases = [
("gcd", [48, 180], 12),
("lcm", [4, 6, 10], 60),
("is_prime", [97], True),
("is_prime", ["2305843009213693951"], True),
("next_prime", [100], 101),
("previous_prime", [100], 97),
("nth_prime", [10], 29),
("totient", [36], 12),
("mod", [-7, 3], 2),
("mod_pow", [2, 100, 97], 16),
("mod_inverse", [3, 11], 4),
("binomial", [49, 6], 13983816),
("permutations", [5, 2], 20),
("fibonacci", [50], 12586269025),
("factorial", [10], 3628800),
]
for operation, values, expected in cases:
with self.subTest(operation=operation, values=values):
self.assertEqual(number_theory(operation, values)["result"], expected)
def test_big_results_are_strings(self):
r = number_theory("factorial", [30])
self.assertEqual(r["result"], "265252859812191058636308480000000")
self.assertEqual(r["digits"], 33)
def test_factorize(self):
r = number_theory("factorize", [360])
self.assertEqual(r["result"], {"2": 3, "3": 2, "5": 1})
self.assertEqual(r["product_form"], "2^3 * 3^2 * 5")
def test_crt(self):
self.assertEqual(number_theory("crt", [2, 3, 2], moduli=[3, 5, 7])["result"], 23)
def test_base_convert(self):
self.assertEqual(number_theory("base_convert", ["ff"], from_base=16, to_base=2)["result"], "11111111")
self.assertEqual(number_theory("base_convert", ["0x1F"], from_base=16, to_base=10)["result"], "31")
self.assertEqual(number_theory("base_convert", ["-10"], to_base=2)["result"], "-1010")
def test_continued_fraction(self):
self.assertEqual(number_theory("continued_fraction", ["415/93"])["result"], [4, 2, 6, 7])
def test_primes_in_range(self):
self.assertEqual(number_theory("primes_in_range", [10, 30])["result"], [11, 13, 17, 19, 23, 29])
def test_arity_errors(self):
with self.assertRaisesRegex(MathError, "values"):
number_theory("mod_pow", [2, 3])
def test_no_inverse(self):
with self.assertRaisesRegex(MathError, "no inverse"):
number_theory("mod_inverse", [2, 4])
def test_non_integer(self):
with self.assertRaisesRegex(MathError, "not an integer"):
number_theory("gcd", ["1.5", 3])
class UnitsTest(MathTestCase):
def test_speed(self):
self.assertApprox(convert_units("100 km/h", "m/s")["magnitude"], 27.7777777778)
def test_temperature(self):
self.assertApprox(convert_units("25 degC", "degF")["magnitude"], 77)
def test_quantity_arithmetic(self):
self.assertApprox(convert_units("5 km/h * 2 h", "m")["magnitude"], 10000)
def test_force(self):
self.assertApprox(convert_units("1.5 kg * 9.81 m/s^2", "N")["magnitude"], 14.715)
def test_base_units(self):
r = convert_units("3 kWh")
self.assertApprox(r["magnitude"], 10_800_000)
self.assertIn("note", r)
def test_data_units(self):
self.assertApprox(convert_units("1 GiB", "MB")["magnitude"], 1073.741824)
def test_incompatible(self):
with self.assertRaisesRegex(MathError, "incompatible"):
convert_units("5 kg", "m")
def test_unknown_unit(self):
with self.assertRaisesRegex(MathError, "unknown"):
convert_units("5 flurbs", "m")

72
tests/test_overload.py Normal file
View file

@ -0,0 +1,72 @@
"""Overload protection in the executor: queue wait limit and cancellation (real worker processes)."""
import asyncio
import time
import unittest
from mcp_calculator.config import load_settings
from mcp_calculator.executor import ComputationError, Executor
from tests.support import slow
async def _warm(executor: Executor) -> None:
deadline = time.time() + 30
while not executor.healthy and time.time() < deadline:
await asyncio.sleep(0.05)
@slow
class QueueWaitTest(unittest.IsolatedAsyncioTestCase):
async def test_waiting_longer_than_the_limit_is_rejected(self):
env = {"MCP_CALC_WORKERS": "1", "MCP_CALC_TIMEOUT_SECONDS": "5", "MCP_CALC_MAX_QUEUE_WAIT_SECONDS": "0.5"}
executor = Executor(load_settings([], env=env))
executor.start()
self.addCleanup(executor.stop)
await _warm(executor)
blocker = asyncio.create_task(executor.run("algebra.evaluate", expression="9^9^9^9"))
await asyncio.sleep(0.2)
started = time.perf_counter()
with self.assertRaisesRegex(ComputationError, "no worker became free") as ctx:
await executor.run("algebra.evaluate", expression="1+1")
waited = time.perf_counter() - started
self.assertEqual(ctx.exception.kind, "busy")
self.assertGreaterEqual(waited, 0.45)
self.assertLess(waited, 2)
blocker.cancel()
with self.assertRaises(asyncio.CancelledError):
await blocker
@slow
class CancellationTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.executor = Executor(load_settings([], env={"MCP_CALC_WORKERS": "1", "MCP_CALC_TIMEOUT_SECONDS": "30"}))
self.executor.start()
self.addCleanup(self.executor.stop)
await _warm(self.executor)
async def test_cancelling_a_running_computation_frees_the_worker(self):
running = asyncio.create_task(self.executor.run("algebra.evaluate", expression="9^9^9^9"))
await asyncio.sleep(0.5)
running.cancel()
with self.assertRaises(asyncio.CancelledError):
await running
self.assertEqual(self.executor.inflight, 0)
started = time.perf_counter()
self.assertEqual((await self.executor.run("algebra.evaluate", expression="1+1"))["exact"], "2")
self.assertLess(time.perf_counter() - started, 10, "the worker must be terminated, not left computing for 30 s")
async def test_cancelling_a_queued_computation_never_starts_it(self):
running = asyncio.create_task(self.executor.run("algebra.evaluate", expression="9^9^9^9"))
await asyncio.sleep(0.2)
queued = asyncio.create_task(self.executor.run("algebra.evaluate", expression="2+2"))
await asyncio.sleep(0.2)
self.assertEqual(self.executor.inflight, 2)
queued.cancel()
with self.assertRaises(asyncio.CancelledError):
await queued
self.assertEqual(self.executor.inflight, 1)
running.cancel()
with self.assertRaises(asyncio.CancelledError):
await running

117
tests/test_parsing.py Normal file
View file

@ -0,0 +1,117 @@
import sympy as sp
from mcp_calculator.config import Limits
from mcp_calculator.ops.context import MathError, set_limits
from mcp_calculator.ops.parsing import apply_assumptions, parse, parse_symbols
from tests.support import MathTestCase
x, y = sp.symbols("x y")
VALID = [
("2x + 3(x+1)", 5 * x + 3),
("x^2", x**2),
("x**2", x**2),
("0.1 + 0.2", sp.Rational(3, 10)),
("5!", 120),
("sin(30*deg)", sp.Rational(1, 2)),
("e^(I*pi)", -1),
("ln(e)", 1),
("log(8, 2)", 3),
("log10(1000)", 3),
("abs(-3)", 3),
("max(3, 7)", 7),
("10 % 3", 1),
("1e5", 100000),
("2.5e-3", sp.Rational(1, 400)),
("√8", 2 * sp.sqrt(2)),
("3×4÷2", 6),
("2π", 2 * sp.pi),
("x²", x**2),
("arcsin(1)", sp.pi / 2),
("inf", sp.oo),
]
MALICIOUS = [
"__import__('os').system('ls')",
"().__class__.__bases__",
"x.subs(x, 1)",
"lambda x: x",
"open('/etc/passwd')",
"import os",
"getattr(x, 'y')",
"[1, 2, 3]",
"Symbol('x')",
"_x + 1",
"a; b",
"exec\n('1')",
"{1: 2}",
"x if x else y",
"`ls`",
"\\x41",
]
class ParseTest(MathTestCase):
def test_valid_expressions(self):
for text, expected in VALID:
with self.subTest(text=text):
result = parse(text)
self.assertTrue(result == expected or sp.simplify(result - expected) == 0, f"{text!r} -> {result}")
def test_relations(self):
self.assertEqual(parse("x^2 = 4"), sp.Eq(x**2, 4))
self.assertEqual(parse("x^2 == 4"), sp.Eq(x**2, 4))
self.assertEqual(parse("x != 3"), sp.Ne(x, 3))
self.assertEqual(parse("x <= 3"), sp.Le(x, 3))
self.assertEqual(parse("x^2 > 4"), sp.Gt(x**2, 4))
def test_only_one_equals_sign(self):
self.assertEqual(parse("x = 1"), sp.Eq(x, 1))
with self.assertRaisesRegex(MathError, "only one '='"):
parse("x = 1 = 2")
def test_multi_letter_names_are_single_symbols(self):
rate, time = sp.symbols("rate time")
self.assertEqual(parse("rate*time"), rate * time)
def test_exact_false_keeps_floats(self):
self.assertIsInstance(parse("0.5", exact=False), sp.Float)
def test_empty_expression(self):
with self.assertRaisesRegex(MathError, "empty"):
parse(" ")
def test_syntax_error_message(self):
with self.assertRaisesRegex(MathError, "syntax error|could not parse"):
parse("2 +* 3")
def test_length_limit(self):
set_limits(Limits(max_input_length=10))
with self.assertRaisesRegex(MathError, "longer than 10"):
parse("1+1+1+1+1+1+1")
class SecurityTest(MathTestCase):
def test_code_is_rejected(self):
for text in MALICIOUS:
with self.subTest(text=text), self.assertRaises(MathError):
parse(text)
def test_python_builtins_are_not_reachable(self):
# Unknown names become plain symbols; they never resolve to Python builtins.
self.assertEqual(parse("eval"), sp.Symbol("eval"))
self.assertEqual(parse("exec"), sp.Symbol("exec"))
with self.assertRaises(MathError):
parse("globals()")
class SymbolTest(MathTestCase):
def test_parse_symbols(self):
self.assertEqual(parse_symbols("x, y"), [x, y])
self.assertEqual(parse_symbols(["x"]), [x])
with self.assertRaises(MathError):
parse_symbols(["sin"])
def test_apply_assumptions(self):
expr, mapping = apply_assumptions(sp.sqrt(x**2), "positive")
self.assertEqual(sp.simplify(expr), mapping[x])

108
tests/test_server.py Normal file
View file

@ -0,0 +1,108 @@
"""Tool layer tests through a real MCP client (in-process, inline executor)."""
from mcp_calculator.metrics import REGISTRY
from tests.support import ClientTestCase, in_process_client
EXPECTED_TOOLS = {
"evaluate",
"transform",
"solve",
"calculus",
"analyze_function",
"solve_ode",
"matrix",
"statistics",
"regression",
"probability",
"hypothesis_test",
"number_theory",
"convert_units",
}
def tool_calls(tool: str, status: str, client: str = "local") -> float:
return REGISTRY.get_sample_value("mcp_calc_tool_calls_total", {"tool": tool, "client": client, "status": status}) or 0.0
class DiscoveryTest(ClientTestCase):
async def test_tool_list(self):
async with in_process_client() as client:
tools = {t.name: t for t in (await client.list_tools()).tools}
self.assertEqual(set(tools), EXPECTED_TOOLS)
for tool in tools.values():
with self.subTest(tool=tool.name):
self.assertGreater(len(tool.description or ""), 40)
self.assertIs(tool.annotations.read_only_hint, True)
self.assertIs(tool.annotations.open_world_hint, False)
async def test_schema_has_descriptions_and_enums(self):
async with in_process_client() as client:
tools = {t.name: t for t in (await client.list_tools()).tools}
props = tools["calculus"].input_schema["properties"]
self.assertIn("derivative", props["operation"]["enum"])
self.assertTrue(props["variable"]["description"])
async def test_instructions_are_sent(self):
async with in_process_client() as client:
self.assertIn("Which tool", client.instructions or "")
self.assertEqual(client.server_info.name, "calculator")
async def test_syntax_resource(self):
async with in_process_client() as client:
resources = (await client.list_resources()).resources
self.assertTrue(any(str(r.uri) == "calculator://syntax" for r in resources))
class ToolCallTest(ClientTestCase):
async def test_evaluate(self):
self.assertEqual((await self.call("evaluate", expression="2^64"))["exact"], "18446744073709551616")
async def test_solve_accepts_single_string(self):
r = await self.call("solve", equations="x^2 = 9", domain="real")
self.assertEqual(r["solutions"], [{"x": "-3"}, {"x": "3"}])
async def test_calculus_and_chaining(self):
derivative = await self.call("calculus", operation="derivative", expression="x^3 - 6x^2 + 9x")
# 'result' is valid input syntax for the next call
roots = await self.call("solve", equations=derivative["result"])
self.assertEqual(roots["solutions"], [{"x": "1"}, {"x": "3"}])
async def test_matrix(self):
self.assertEqual((await self.call("matrix", operation="determinant", matrix=[[1, 2], [3, 4]]))["result"], "-2")
async def test_statistics(self):
self.assertEqual((await self.call("statistics", data=[1, 2, 3, 4]))["mean"], 2.5)
async def test_number_theory_big_ints_as_strings(self):
r = await self.call("number_theory", operation="is_prime", values=["170141183460469231731687303715884105727"])
self.assertIs(r["result"], True)
async def test_convert_units(self):
r = await self.call("convert_units", quantity="1 mi", to_unit="km")
self.assertAlmostEqual(r["magnitude"], 1.609344, places=9)
class ToolErrorTest(ClientTestCase):
async def test_math_error_is_tool_error(self):
self.assertIn("singular", await self.call_error("matrix", operation="inverse", matrix=[[1, 2], [2, 4]]))
async def test_injection_is_rejected(self):
text = await self.call_error("evaluate", expression="__import__('os').system('id')")
self.assertRegex(text, "unsupported character|not allowed")
async def test_schema_validation_error(self):
self.assertIn("operation", await self.call_error("matrix", operation="explode", matrix=[[1]]))
class ToolMetricsTest(ClientTestCase):
async def test_calls_are_counted_by_outcome(self):
ok_before, error_before = tool_calls("evaluate", "ok"), tool_calls("evaluate", "error")
await self.call("evaluate", expression="1+1")
await self.call_error("evaluate", expression="1/")
self.assertEqual(tool_calls("evaluate", "ok"), ok_before + 1)
self.assertEqual(tool_calls("evaluate", "error"), error_before + 1)
async def test_duration_is_recorded(self):
before = REGISTRY.get_sample_value("mcp_calc_tool_duration_seconds_count", {"tool": "statistics"}) or 0.0
await self.call("statistics", data=[1, 2, 3])
self.assertEqual(REGISTRY.get_sample_value("mcp_calc_tool_duration_seconds_count", {"tool": "statistics"}), before + 1)

112
tests/test_stats.py Normal file
View file

@ -0,0 +1,112 @@
import math
from mcp_calculator.ops.context import MathError
from mcp_calculator.ops.stats import hypothesis_test, probability, regression, statistics
from tests.support import MathTestCase
class StatisticsTest(MathTestCase):
def test_basic(self):
r = statistics([2, 4, 4, 4, 5, 5, 7, 9])
self.assertEqual(r["mean"], 5)
self.assertEqual(r["median"], 4.5)
self.assertEqual(r["mode"], [4.0])
self.assertApprox(r["std_dev"], 2.13808993529939)
def test_population(self):
r = statistics([2, 4, 4, 4, 5, 5, 7, 9], sample=False)
self.assertEqual(r["std_dev"], 2)
self.assertTrue(r["kind"].startswith("population"))
def test_percentiles(self):
self.assertApprox(statistics(list(range(1, 101)), percentiles=[90])["percentiles"]["90"], 90.1)
def test_paired(self):
self.assertApprox(statistics([1, 2, 3, 4, 5], y=[2, 4, 6, 8, 10])["paired"]["pearson_r"], 1)
def test_single_value(self):
self.assertEqual(statistics([3])["mean"], 3)
def test_rejects_empty(self):
with self.assertRaises(MathError):
statistics([])
class RegressionTest(MathTestCase):
def test_linear(self):
r = regression([1, 2, 3, 4], [3, 5, 7, 9])
self.assertApprox(r["coefficients"]["intercept"], 1)
self.assertApprox(r["coefficients"]["slope"], 2)
self.assertApprox(r["r_squared"], 1)
def test_prediction(self):
self.assertApprox(regression([1, 2, 3], [2, 4, 6], predict=[10])["predictions"][0]["y"], 20)
def test_polynomial(self):
self.assertApprox(regression([0, 1, 2, 3], [1, 2, 5, 10], model="polynomial", degree=2)["coefficients"]["x^2"], 1)
def test_exponential(self):
xs = [0, 1, 2, 3]
r = regression(xs, [2 * math.exp(0.5 * x) for x in xs], model="exponential")
self.assertApprox(r["coefficients"]["a"], 2)
self.assertApprox(r["coefficients"]["b"], 0.5)
def test_length_mismatch(self):
with self.assertRaisesRegex(MathError, "same length"):
regression([1, 2], [1, 2, 3])
class ProbabilityTest(MathTestCase):
def test_normal_between(self):
r = probability("normal", "between", {"mean": 100, "std": 15}, lower=85, upper=115)
self.assertApprox(r["result"], 0.682689492137)
def test_binomial_discrete_semantics(self):
r = probability("binomial", "sf", {"n": 10, "p": 0.5}, x=7)
self.assertApprox(r["result"], 56 / 1024)
self.assertIn("P(X >= 8)", r["meaning"])
def test_pmf(self):
self.assertApprox(probability("poisson", "pdf", {"lambda": 2}, x=0)["result"], math.exp(-2))
def test_quantile(self):
self.assertApprox(probability("normal", "quantile", q=0.975)["result"], 1.959963985)
def test_summary(self):
self.assertApprox(probability("exponential", "summary", {"rate": 2})["result"]["mean"], 0.5)
def test_missing_parameter(self):
with self.assertRaisesRegex(MathError, "needs parameter"):
probability("binomial", "cdf", {"n": 10}, x=3)
def test_unknown_parameter(self):
with self.assertRaisesRegex(MathError, "unknown parameter"):
probability("normal", "cdf", {"mu": 0}, x=0)
class HypothesisTestTest(MathTestCase):
def test_one_sample_t(self):
r = hypothesis_test("one_sample_t", sample=[5.1, 4.9, 5.3, 5.5, 5.0], mu=5)
self.assertApprox(r["p_value"], 0.2115789946)
self.assertIs(r["reject_null"], False)
def test_two_sample_welch(self):
r = hypothesis_test("two_sample_t", sample=[1, 2, 3, 4, 5], sample2=[6, 7, 8, 9, 10])
self.assertIs(r["reject_null"], True)
self.assertEqual(r["variant"], "Welch")
def test_chi2_independence(self):
r = hypothesis_test("chi2_independence", table=[[10, 20], [30, 40]])
self.assertEqual(r["df"], 1)
self.assertIn("cramers_v", r)
def test_goodness_of_fit_accepts_probabilities(self):
self.assertApprox(hypothesis_test("chi2_goodness_of_fit", sample=[50, 50], expected=[0.5, 0.5])["p_value"], 1)
def test_binomial(self):
r = hypothesis_test("binomial", sample=[9, 10], p=0.5, alternative="greater")
self.assertApprox(r["p_value"], 11 / 1024)
def test_missing_input(self):
with self.assertRaisesRegex(MathError, "needs 'sample2'"):
hypothesis_test("paired_t", sample=[1, 2, 3])

206
tests/test_transports.py Normal file
View file

@ -0,0 +1,206 @@
"""End-to-end tests with real subprocesses: worker pool, backpressure, HTTP and stdio transport."""
from __future__ import annotations
import asyncio
import json
import os
import signal
import socket
import subprocess
import sys
import time
import unittest
import urllib.error
import urllib.request
import httpx2
from mcp import Client, StdioServerParameters
from mcp.client.streamable_http import streamable_http_client
from mcp_calculator.config import load_settings
from mcp_calculator.executor import ComputationError, Executor
from tests.support import slow
TOKEN = "test-token-0123456789"
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _http(method: str, url: str, body: dict | None = None, token: str | None = None) -> tuple[int, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body).encode() if body is not None else None
try:
with urllib.request.urlopen(urllib.request.Request(url, data=data, headers=headers, method=method), timeout=30) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode()
def _start_server(port: int, **env: str) -> subprocess.Popen:
full_env = {**os.environ, "MCP_CALC_TRANSPORT": "http", "MCP_CALC_PORT": str(port), "MCP_CALC_WORKERS": "1", **env}
proc = subprocess.Popen([sys.executable, "-m", "mcp_calculator"], env=full_env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
# Poll readiness (503 until the workers are warm). Always pause between polls: every request
# is access-logged, and a flood of log lines would fill the stderr pipe and block the server.
deadline = time.time() + 30
while time.time() < deadline:
try:
if _http("GET", f"http://127.0.0.1:{port}/readyz")[0] == 200:
return proc
except OSError:
pass
time.sleep(0.2)
proc.kill()
raise RuntimeError(f"server did not start: {proc.communicate()[1]}")
# --------------------------------------------------------------------------- worker pool
@slow
class WorkerPoolTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.executor = Executor(load_settings([], env={"MCP_CALC_TIMEOUT_SECONDS": "2", "MCP_CALC_WORKERS": "1"}))
self.executor.start()
self.addCleanup(self.executor.stop)
async def test_computes(self):
self.assertEqual((await self.executor.run("algebra.evaluate", expression="2^10"))["exact"], "1024")
async def test_timeout_kills_worker_and_recovers(self):
started = time.perf_counter()
with self.assertRaisesRegex(ComputationError, "timeout") as ctx:
await self.executor.run("algebra.evaluate", expression="9^9^9^9")
self.assertEqual(ctx.exception.kind, "timeout")
self.assertLess(time.perf_counter() - started, 5)
# the killed worker is replaced transparently
self.assertEqual((await self.executor.run("algebra.evaluate", expression="1+1"))["exact"], "2")
self.assertTrue(self.executor.healthy)
async def test_ready_only_after_warm_up(self):
executor = Executor(load_settings([], env={"MCP_CALC_WORKERS": "1"}))
executor.start()
self.addCleanup(executor.stop)
self.assertFalse(executor.healthy, "workers cannot have loaded SymPy/SciPy yet")
deadline = time.time() + 30
while not executor.healthy and time.time() < deadline:
await asyncio.sleep(0.05)
self.assertTrue(executor.healthy)
async def test_math_error(self):
with self.assertRaisesRegex(ComputationError, "unsupported character") as ctx:
await self.executor.run("algebra.evaluate", expression="'x'")
self.assertEqual(ctx.exception.kind, "error")
@slow
class BackpressureTest(unittest.IsolatedAsyncioTestCase):
async def test_requests_beyond_capacity_are_rejected_immediately(self):
env = {"MCP_CALC_TIMEOUT_SECONDS": "1.5", "MCP_CALC_WORKERS": "1", "MCP_CALC_MAX_QUEUE": "1"}
executor = Executor(load_settings([], env=env))
executor.start()
self.addCleanup(executor.stop)
self.assertEqual(executor.capacity, 2)
slow_tasks = [asyncio.create_task(executor.run("algebra.evaluate", expression="9^9^9^9")) for _ in range(2)]
await asyncio.sleep(0.1)
self.assertEqual(executor.inflight, 2)
started = time.perf_counter()
with self.assertRaisesRegex(ComputationError, "busy") as ctx:
await executor.run("algebra.evaluate", expression="1+1")
self.assertEqual(ctx.exception.kind, "busy")
self.assertLess(time.perf_counter() - started, 0.5, "busy rejection must not wait")
results = await asyncio.gather(*slow_tasks, return_exceptions=True)
self.assertTrue(all(isinstance(r, ComputationError) and r.kind == "timeout" for r in results), results)
self.assertEqual(executor.inflight, 0)
# capacity is free again
self.assertEqual((await executor.run("algebra.evaluate", expression="1+1"))["exact"], "2")
# --------------------------------------------------------------------------- HTTP
@slow
class HttpTransportTest(unittest.IsolatedAsyncioTestCase):
@classmethod
def setUpClass(cls) -> None:
port = _free_port()
cls.base = f"http://127.0.0.1:{port}"
cls.proc = _start_server(port, MCP_CALC_AUTH_TOKENS=f"tests:{TOKEN}", MCP_CALC_LOG_LEVEL="WARNING")
@classmethod
def tearDownClass(cls) -> None:
cls.proc.terminate()
cls.proc.communicate(timeout=30)
def test_health_endpoints_are_public(self):
self.assertEqual(json.loads(_http("GET", f"{self.base}/healthz")[1])["status"], "ok")
self.assertEqual(json.loads(_http("GET", f"{self.base}/readyz")[1])["status"], "ready")
def test_mcp_endpoint_requires_token(self):
self.assertEqual(_http("POST", f"{self.base}/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})[0], 401)
self.assertEqual(_http("POST", f"{self.base}/mcp", {}, token="wrong")[0], 401)
async def test_mcp_over_http(self):
async with httpx2.AsyncClient(headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30) as http:
async with Client(streamable_http_client(f"{self.base}/mcp", http_client=http)) as client:
self.assertEqual(len((await client.list_tools()).tools), 13)
result = await client.call_tool("evaluate", {"expression": "sqrt(2)", "precision": 30})
self.assertEqual(result.structured_content["numeric"], "1.41421356237309504880168872421")
def test_metrics_endpoint(self):
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "convert_units", "arguments": {"quantity": "1 m", "to_unit": "cm"}}}
self.assertEqual(_http("POST", f"{self.base}/mcp", body, token=TOKEN)[0], 200)
status, text = _http("GET", f"{self.base}/metrics") # public, like the probes
self.assertEqual(status, 200)
self.assertIn('mcp_calc_tool_calls_total{client="tests",status="ok",tool="convert_units"}', text)
for name in ("mcp_calc_tool_duration_seconds_bucket", "mcp_calc_inflight_computations", "mcp_calc_capacity 21.0", "mcp_calc_build_info"):
self.assertIn(name, text)
@slow
class HttpLifecycleTest(unittest.TestCase):
def test_metrics_can_be_disabled(self):
port = _free_port()
proc = _start_server(port, MCP_CALC_METRICS="false")
try:
self.assertEqual(_http("GET", f"http://127.0.0.1:{port}/metrics")[0], 404)
finally:
proc.terminate()
proc.communicate(timeout=30)
def test_sigterm_stops_worker_pool_cleanly(self):
port = _free_port()
proc = _start_server(port, MCP_CALC_LOG_LEVEL="INFO")
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "evaluate", "arguments": {"expression": "1+1"}}}
self.assertEqual(_http("POST", f"http://127.0.0.1:{port}/mcp", body)[0], 200)
proc.send_signal(signal.SIGTERM)
# communicate() also waits for the multiprocessing resource tracker, which shares stderr
# and reports leaked semaphores only when it exits.
_, stderr = proc.communicate(timeout=30)
self.assertIn("worker pool stopped", stderr)
self.assertNotIn("leaked", stderr)
# --------------------------------------------------------------------------- stdio
@slow
class StdioTransportTest(unittest.IsolatedAsyncioTestCase):
async def test_mcp_over_stdio(self):
params = StdioServerParameters(
command=sys.executable,
args=["-m", "mcp_calculator"],
env={**os.environ, "MCP_CALC_WORKERS": "1", "MCP_CALC_LOG_LEVEL": "WARNING"},
)
async with Client(params) as client:
result = await client.call_tool("solve", {"equations": ["x + y = 3", "x - y = 1"]})
self.assertEqual(result.structured_content["solutions"], [{"x": "2", "y": "1"}])

930
uv.lock generated Normal file
View file

@ -0,0 +1,930 @@
version = 1
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
]
[[package]]
name = "flexcache"
version = "0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816, upload-time = "2024-03-09T03:21:07.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263, upload-time = "2024-03-09T03:21:05.635Z" },
]
[[package]]
name = "flexparser"
version = "0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799, upload-time = "2024-11-07T02:00:56.249Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625, upload-time = "2024-11-07T02:00:54.523Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore2"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
]
[[package]]
name = "httpx2"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
]
[[package]]
name = "httpx2-jsfetch"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
name = "idna"
version = "3.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "mcp"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx2" },
{ name = "jsonschema" },
{ name = "mcp-types" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" },
]
[[package]]
name = "mcp-calculator"
version = "1.2.0"
source = { editable = "." }
dependencies = [
{ name = "mcp" },
{ name = "mpmath" },
{ name = "numpy" },
{ name = "pebble" },
{ name = "pint" },
{ name = "prometheus-client" },
{ name = "scipy" },
{ name = "starlette" },
{ name = "sympy" },
{ name = "uvicorn" },
]
[package.optional-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "mcp", specifier = ">=2.2,<3" },
{ name = "mpmath", specifier = ">=1.3" },
{ name = "numpy", specifier = ">=2.0" },
{ name = "pebble", specifier = ">=5.1" },
{ name = "pint", specifier = ">=0.24" },
{ name = "prometheus-client", specifier = ">=0.20" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
{ name = "scipy", specifier = ">=1.13" },
{ name = "starlette", specifier = ">=0.40" },
{ name = "sympy", specifier = ">=1.13" },
{ name = "uvicorn", specifier = ">=0.30" },
]
provides-extras = ["dev"]
[[package]]
name = "mcp-types"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" },
{ url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" },
{ url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" },
{ url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" },
{ url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" },
{ url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" },
{ url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" },
{ url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" },
{ url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" },
{ url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" },
{ url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" },
{ url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" },
{ url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" },
{ url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" },
{ url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" },
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" },
{ url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" },
{ url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" },
{ url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" },
{ url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" },
{ url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" },
{ url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" },
{ url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" },
{ url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" },
{ url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" },
{ url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" },
{ url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" },
{ url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" },
{ url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" },
]
[[package]]
name = "pebble"
version = "5.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/60/daeaa992b73667dc6976fd6ed137aaff88801ad497e3ba19464d5dcf1c1d/pebble-5.2.2.tar.gz", hash = "sha256:eb49a0f6862f7e75df4a40107f1c8c267cf8adfbd6d29a0dac29d17ac188c878", size = 41367, upload-time = "2026-09-04T13:15:22.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/34/acd4067ea2dadc43038d7940836d893904671e404978eb455887cb3fb0f9/pebble-5.2.2-py3-none-any.whl", hash = "sha256:1058e3ed41c6bb5cc16fcf6a249502cd4637a597211b997d9a0a499f46bc91a4", size = 36355, upload-time = "2026-09-04T13:15:21.256Z" },
]
[[package]]
name = "pint"
version = "0.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flexcache" },
{ name = "flexparser" },
{ name = "platformdirs" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/bc/2c38c32e0fb1f966d3695f4493a3f3ce2cc0cca1bbe7c958b92261b31af9/pint-0.26.1.tar.gz", hash = "sha256:1bbde36eae57a5a289cd05081c6405618a5899814940752064ce351cd0204f71", size = 273631, upload-time = "2026-09-10T21:16:49.712Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/5c/9507ea3c732f8a259f45ddf190a741e9bf7d3fe229b5d473917ee6efbf1e/pint-0.26.1-py3-none-any.whl", hash = "sha256:e982b129415c09c63308f314ae44697d83e5c96f253bcc0d5b833fc3329eb6d4", size = 326478, upload-time = "2026-09-10T21:16:48.316Z" },
]
[[package]]
name = "platformdirs"
version = "4.11.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc", size = 37182, upload-time = "2026-09-08T22:20:42.866Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" },
]
[[package]]
name = "prometheus-client"
version = "0.26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
]
[[package]]
name = "pyjwt"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pywin32"
version = "312"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
{ url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
{ url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
{ url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
{ url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
{ url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
{ url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
{ url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
{ url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "rpds-py"
version = "2026.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
]
[[package]]
name = "ruff"
version = "0.16.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
]
[[package]]
name = "scipy"
version = "1.18.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" },
{ url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" },
{ url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" },
{ url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" },
{ url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" },
{ url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" },
{ url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" },
{ url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" },
{ url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" },
{ url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" },
{ url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" },
{ url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" },
{ url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" },
{ url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" },
{ url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" },
{ url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" },
{ url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" },
{ url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" },
{ url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" },
{ url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" },
{ url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" },
{ url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" },
{ url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" },
{ url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" },
{ url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" },
{ url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" },
{ url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" },
{ url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" },
{ url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" },
{ url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" },
{ url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" },
{ url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" },
{ url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" },
{ url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" },
{ url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" },
{ url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" },
{ url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" },
{ url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" },
{ url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" },
{ url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" },
{ url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" },
{ url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" },
{ url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" },
{ url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" },
{ url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" },
{ url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" },
{ url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" },
{ url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" },
{ url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" },
]
[[package]]
name = "sse-starlette"
version = "3.4.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" },
]
[[package]]
name = "starlette"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.52.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
]