diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..67f287f --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,18 @@ +# Pending CI workflow + +`test-workflow.yml` belongs at `.github/workflows/test.yml`. It is parked here +because the session that wrote it pushed with a token lacking GitHub's +`workflow` scope, which refuses any commit that creates or updates a file under +`.github/workflows/`. + +To install it: + +```bash +git mv ci/test-workflow.yml .github/workflows/test.yml +rmdir ci 2>/dev/null; git rm -q ci/README.md +git commit -m "Add CI job running the test suite on push and PR" +git push +``` + +Until then nothing runs `pipenv run test` automatically — the existing +`build.yml` triggers only on tag pushes. diff --git a/.github/workflows/test-workflow.yml b/.github/workflows/test-workflow.yml new file mode 100644 index 0000000..e409f17 --- /dev/null +++ b/.github/workflows/test-workflow.yml @@ -0,0 +1,35 @@ +name: Test + +# The build workflow runs only on tag pushes, so nothing checked ordinary +# commits before this existed. Gating releases on tests is M6's job; this is +# the job it will depend on. +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12"] + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pipenv" + - name: Install pipenv + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade pipenv + # `--dev` matters: the build workflow's bare `pipenv install` skips + # dev-packages, so pytest would not be present. + - name: Install dependencies + run: pipenv install --dev --deploy + - name: Run tests + run: pipenv run test diff --git a/CLAUDE.md b/CLAUDE.md index a2a9bfa..d1f1899 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,41 +5,51 @@ Symbolic mathematics for LLMs, delivered as an [MCP](https://modelcontextprotoco **Stack:** Python 3.12, `pipenv`, [FastMCP](https://github.com/jlowin/fastmcp), SymPy, PyInstaller (for standalone binaries). Remote: `github.com/EquationsCP/equationscp` (CI/releases live under the `EquationsCP` org). ## Files -- `server.py` — FastMCP server. Defines the `solve_linear_equation` async tool. Runs SymPy in a thread pool executor (`run_in_executor`) so the async loop isn't blocked. Server name is `sympy-solver`. `__main__` calls `server.run()` (stdio by default). -- `solver.py` — the math. `sympy_solve_linear_equation(equation_str, variable_str)` parses `"lhs = rhs"`, solves with `sp.solve`, and hand-builds the step list (subtract constant, divide by coefficient). `format_solution(...)` shapes the dict for the output schema. -- `schemas.py` — Pydantic models: `EquationInput` (equation, variable), `Solution` (variable, value), `EquationOutput` (solution, steps). +- `server.py` — FastMCP server. Defines the `solve_linear_equation` async tool, dispatching via `asyncio.to_thread`. Server name is `sympy-solver`. Every failure is returned as a typed outcome, never raised. `__main__` calls `multiprocessing.freeze_support()` then `server.run()` (stdio by default). +- `guard.py` — untrusted-input gate. `safe_parse(...)` normalizes LLM syntax (`x^2`, `2x`) with SymPy's tokenizer, walks the resulting AST against an allowlist, and only then evaluates. **`parse_expr` on raw input is arbitrary code execution** — see the module docstring before touching this. +- `solver.py` — the math. `_solve(equation_str, variable_str)` returns a dict matching one `EquationOutput` member and raises `SolverError` for typed failures. Checks *degree before solving* (completeness) and substitutes the answer back into the original equation (soundness). Steps are generated from the operations actually performed. +- `sandbox.py` — wall-clock bound. Solves run in a reusable spawned worker process that is killed and replaced on timeout, because a thread cannot be cancelled. +- `schemas.py` — Pydantic models. `EquationOutput` is a discriminated union tagged by `outcome`: `solved` | `no_solution` | `infinite_solutions` | `error`. +- `tests/` — pytest suite (`pipenv run test`). Happy-path tests pin the full step list, not just the value. - `Pipfile` — deps + the `[scripts]` used below. `Pipfile.lock` is committed. - `server.spec` — PyInstaller spec; produces a single-file `console` binary named `server`. - `Taskfile.yaml` — release automation (see below). - `Versionfile` — current version string (e.g. `v0.1.0`), read by the Taskfile to build tags. -- `.github/workflows/build.yml` — CI: on any tag push, builds binaries for linux-amd64, linux-arm64, darwin-arm64 and cuts a **prerelease** GitHub release with the artifacts attached. +- `.github/workflows/test-workflow.yml` — CI: runs `pipenv install --dev` then `pipenv run test` on every push and PR. Note it fires on both `push` and `pull_request`, so a branch in this repo gets two runs per push. +- `.github/workflows/build.yml` — CI: on any tag push, builds binaries for linux-amd64, linux-arm64, darwin-arm64 and cuts a **prerelease** GitHub release with the artifacts attached. Not yet gated on tests (M6). - `docs/` — canonical, in-repo documentation (see below). - `plans/PROJECT_PLAN.md` — the milestone-based project plan. ## The one tool it exposes -`solve_linear_equation` — input `{equation, variable}`, output `{solution: {variable, value}, steps: [...]}`. +`solve_linear_equation` — input `{equation, variable}`, output tagged by `outcome`. ```json // in { "equation": "2*x + 3 = 7", "variable": "x" } // out -{ "solution": { "variable": "x", "value": 2.0 }, - "steps": ["Original equation: 2*x + 3 = 7", "...", "Solution: x = 2.0"] } +{ "outcome": "solved", + "solution": { "variable": "x", "value": "2", "float_value": 2.0 }, + "steps": ["Original equation: 2*x + 3 = 7", "...", "Solution: x = 2"] } ``` -Only **linear** equations are supported today. `value` is always coerced to `float`. Non-linear input (coefficient of the variable is 0) raises `ValueError`. +Only **linear** equations are supported. `solution.value` is the **exact** form as a string (`"1/3"`, `"sqrt(2)/2"`, `"-I"`); `float_value` is the approximation alongside it, `null` when no real float exists. + +Anything whose degree in the variable isn't 1 is refused with `error: "not_linear"` — *including* equations that have a linear term (`x**2 + x = 4`). This matters: the earlier coefficient-based check missed those and silently returned one root of a quadratic. Back-substitution cannot catch that class of bug, only the degree check can. ## Running it ```bash -pipenv install # first-time setup +pipenv install --dev # first-time setup (--dev for the test suite) pipenv run stdio-server # fastmcp run server.py (stdio — default MCP transport) -pipenv run http-server # SSE on 0.0.0.0:4242 +pipenv run http-server # SSE on 127.0.0.1:4242 (no auth — keep it on loopback) pipenv run dev-server # fastmcp dev server.py pipenv run inspector # MCP Inspector UI at http://127.0.0.1:6274 (needs npx) pipenv run build # pyinstaller server.spec -> dist/server +pipenv run test # pytest ``` -Note: `README.md` occasionally references `src/server.py`, but the files actually live at the repo root (`server.py`, not `src/server.py`). Trust the root paths. +All files live at the repo root (`server.py`, not `src/server.py`). + +`fastmcp` is pinned to `==2.3.3` in the Pipfile. An unpinned spec re-resolves to 3.x, a different API whose wheel resolves to a `fastmcp_slim` namespace package with no `Client`. Upgrading is its own task. ## Cutting a release Bump `Versionfile`, then use the Taskfile (requires [Task](https://taskfile.dev)): @@ -52,4 +62,4 @@ The tag push triggers `.github/workflows/build.yml`. Releases are currently all Canonical, in-repo documentation. One markdown page per tool (`docs/README.md` is the index; `docs/linear-equations.md` documents the current tool). **New docs go here.** ## Roadmap (from README "Future Enhancements") -Quadratic equations, systems of linear equations, symbolic/parametric solutions, differential equations, then broader math (calculus, combinatorics, discrete, linear algebra). When adding equation types, the pattern is: new solver fn in `solver.py` + new schemas in `schemas.py` + new `@server.tool` in `server.py`, and add a matching doc page in `docs/`. Full milestone plan in `plans/PROJECT_PLAN.md`. +Quadratic equations, systems of linear equations, symbolic/parametric solutions, differential equations, then broader math (calculus, combinatorics, discrete, linear algebra). When adding equation types, the pattern is: new solver fn in `solver.py` + new schemas in `schemas.py` + new `@server.tool` in `server.py`, a matching doc page in `docs/`, and tests. Every new solver must parse through `guard.safe_parse`, check the shape of its input before solving, and verify its answer before returning it. Full milestone plan in `plans/PROJECT_PLAN.md`. diff --git a/Pipfile b/Pipfile index 003eb1a..290ae83 100644 --- a/Pipfile +++ b/Pipfile @@ -5,18 +5,26 @@ name = "pypi" [packages] sympy = "*" -fastmcp = "*" +# Pinned deliberately. Adding dev-packages forces a full re-resolve, and an +# unpinned spec jumps to fastmcp 3.x — a major version with a different API, +# whose wheel resolves to a `fastmcp_slim` namespace package that does not +# export `Client`. Upgrading is its own piece of work, not a side effect of +# adding a test runner. The previously committed lock pinned this same version. +fastmcp = "==2.3.3" pyinstaller = "*" macholib = "*" [dev-packages] +pytest = "*" +pytest-asyncio = "*" [requires] python_version = "3.12" [scripts] stdio-server = "fastmcp run server.py" -http-server = "fastmcp run server.py --transport sse --host 0.0.0.0 --port 4242" +http-server = "fastmcp run server.py --transport sse --host 127.0.0.1 --port 4242" dev-server = "fastmcp dev server.py" inspector = "npx @modelcontextprotocol/inspector fastmcp run server.py" build = "pyinstaller server.spec" +test = "pytest" diff --git a/Pipfile.lock b/Pipfile.lock index 0edb937..71a83cd 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2ea871d0a1295cfda4186da68c7937bb3d1f183e3c8ca70d8abccaa8e1c71c47" + "sha256": "144f4cd8c054518402dddefeff05486cfbdc6c9bfddc2e6af4b2b197070f39d1" }, "pipfile-spec": 6, "requires": { @@ -18,50 +18,224 @@ "default": { "altgraph": { "hashes": [ - "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", - "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff" + "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", + "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597" ], - "version": "==0.17.4" + "version": "==0.17.5" + }, + "annotated-doc": { + "hashes": [ + "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", + "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb" + ], + "markers": "python_version >= '3.9'", + "version": "==0.0.5" }, "annotated-types": { "hashes": [ - "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", - "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" + "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", + "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" ], - "markers": "python_version >= '3.8'", - "version": "==0.7.0" + "markers": "python_version >= '3.10'", + "version": "==0.8.0" }, "anyio": { "hashes": [ - "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", - "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c" + "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", + "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f" + ], + "markers": "python_version >= '3.10'", + "version": "==4.14.2" + }, + "attrs": { + "hashes": [ + "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", + "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" ], "markers": "python_version >= '3.9'", - "version": "==4.9.0" + "version": "==26.1.0" }, "certifi": { "hashes": [ - "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", - "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3" + "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", + "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" ], - "markers": "python_version >= '3.6'", - "version": "==2025.4.26" + "markers": "python_version >= '3.7'", + "version": "==2026.7.22" + }, + "cffi": { + "hashes": [ + "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", + "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", + "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", + "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", + "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", + "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", + "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", + "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", + "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", + "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", + "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", + "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", + "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", + "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", + "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", + "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", + "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", + "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", + "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", + "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", + "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", + "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", + "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", + "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", + "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", + "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", + "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", + "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", + "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", + "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", + "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", + "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", + "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", + "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", + "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", + "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", + "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", + "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", + "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", + "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", + "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", + "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", + "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", + "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", + "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", + "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", + "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", + "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", + "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", + "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", + "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", + "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", + "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", + "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", + "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", + "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", + "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", + "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", + "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", + "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", + "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", + "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", + "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", + "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", + "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", + "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", + "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", + "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", + "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", + "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", + "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", + "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", + "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", + "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", + "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", + "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", + "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", + "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", + "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", + "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", + "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", + "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", + "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", + "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", + "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", + "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", + "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", + "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", + "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", + "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", + "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", + "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", + "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", + "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", + "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", + "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", + "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", + "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", + "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", + "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f" + ], + "markers": "python_version >= '3.10'", + "version": "==2.1.0" }, "click": { "hashes": [ - "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c", - "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d" + "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", + "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76" ], "markers": "python_version >= '3.10'", - "version": "==8.2.0" + "version": "==8.4.2" + }, + "cryptography": { + "hashes": [ + "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", + "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", + "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", + "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", + "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", + "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", + "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", + "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", + "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", + "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", + "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", + "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", + "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", + "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", + "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", + "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", + "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", + "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", + "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", + "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", + "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", + "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", + "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", + "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", + "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", + "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", + "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", + "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", + "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", + "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", + "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", + "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", + "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", + "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", + "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", + "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", + "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", + "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", + "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", + "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", + "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", + "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", + "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", + "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", + "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", + "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645" + ], + "markers": "python_version >= '3.9' and python_full_version not in '3.9.0, 3.9.1'", + "version": "==50.0.0" }, "exceptiongroup": { "hashes": [ - "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", - "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88" + "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", + "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" ], "markers": "python_version >= '3.7'", - "version": "==1.3.0" + "version": "==1.3.1" }, "fastmcp": { "hashes": [ @@ -98,43 +272,59 @@ }, "httpx-sse": { "hashes": [ - "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", - "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f" + "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", + "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d" ], - "markers": "python_version >= '3.8'", - "version": "==0.4.0" + "markers": "python_version >= '3.9'", + "version": "==0.4.3" }, "idna": { "hashes": [ - "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", - "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", + "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" ], - "markers": "python_version >= '3.6'", - "version": "==3.10" + "markers": "python_version >= '3.9'", + "version": "==3.18" + }, + "jsonschema": { + "hashes": [ + "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", + "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" + ], + "markers": "python_version >= '3.10'", + "version": "==4.26.0" + }, + "jsonschema-specifications": { + "hashes": [ + "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", + "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" + ], + "markers": "python_version >= '3.9'", + "version": "==2025.9.1" }, "macholib": { "hashes": [ - "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", - "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c" + "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", + "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362" ], "index": "pypi", - "version": "==1.16.3" + "version": "==1.16.4" }, "markdown-it-py": { "hashes": [ - "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", - "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", + "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" ], - "markers": "python_version >= '3.8'", - "version": "==3.0.0" + "markers": "python_version >= '3.10'", + "version": "==4.2.0" }, "mcp": { "hashes": [ - "sha256:948e03783859fa35abe05b9b6c0a1d5519be452fc079dc8d7f682549591c1770", - "sha256:ec0646271d93749f784d2316fb5fe6102fb0d1be788ec70a9e2517e8f2722c0e" + "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", + "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7" ], "markers": "python_version >= '3.10'", - "version": "==1.8.1" + "version": "==1.29.0" }, "mdurl": { "hashes": [ @@ -161,199 +351,369 @@ }, "packaging": { "hashes": [ - "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", - "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", + "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" ], "markers": "python_version >= '3.8'", - "version": "==25.0" + "version": "==26.2" + }, + "pycparser": { + "hashes": [ + "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", + "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" + ], + "markers": "python_version >= '3.10'", + "version": "==3.0" }, "pydantic": { "hashes": [ - "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", - "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb" + "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", + "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6" ], "markers": "python_version >= '3.9'", - "version": "==2.11.4" + "version": "==2.13.4" }, "pydantic-core": { "hashes": [ - "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", - "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", - "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", - "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", - "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", - "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", - "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", - "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", - "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", - "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", - "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", - "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", - "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", - "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", - "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", - "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", - "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", - "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", - "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", - "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", - "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", - "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", - "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", - "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", - "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", - "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", - "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", - "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", - "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", - "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", - "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", - "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", - "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", - "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", - "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", - "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", - "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", - "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", - "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", - "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", - "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", - "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", - "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", - "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", - "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", - "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", - "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", - "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", - "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", - "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", - "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", - "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", - "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", - "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", - "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", - "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", - "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", - "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", - "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", - "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", - "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", - "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", - "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", - "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", - "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", - "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", - "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", - "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", - "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", - "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", - "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", - "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", - "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", - "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", - "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", - "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", - "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", - "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", - "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", - "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", - "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", - "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", - "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", - "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", - "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", - "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", - "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", - "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", - "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", - "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", - "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", - "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", - "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", - "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", - "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", - "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", - "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", - "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", - "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d" + "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", + "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", + "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", + "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", + "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", + "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", + "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", + "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29", + "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", + "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", + "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", + "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", + "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", + "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", + "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", + "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", + "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", + "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", + "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9", + "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", + "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", + "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", + "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", + "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", + "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", + "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0", + "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", + "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", + "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", + "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac", + "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", + "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", + "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", + "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", + "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928", + "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6", + "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", + "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", + "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", + "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", + "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", + "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", + "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", + "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", + "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", + "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", + "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", + "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", + "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", + "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", + "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", + "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", + "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", + "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", + "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", + "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", + "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", + "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", + "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", + "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", + "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15", + "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", + "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", + "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", + "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", + "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", + "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", + "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9", + "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", + "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", + "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", + "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", + "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", + "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", + "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", + "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", + "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201", + "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", + "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", + "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", + "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", + "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", + "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", + "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", + "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", + "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", + "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", + "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", + "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", + "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", + "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", + "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", + "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", + "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", + "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", + "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", + "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", + "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", + "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", + "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1", + "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76", + "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", + "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", + "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", + "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", + "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066", + "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", + "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", + "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", + "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", + "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", + "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49", + "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", + "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", + "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", + "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", + "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", + "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", + "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", + "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae" ], "markers": "python_version >= '3.9'", - "version": "==2.33.2" + "version": "==2.46.4" }, "pydantic-settings": { "hashes": [ - "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", - "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268" + "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", + "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f" ], - "markers": "python_version >= '3.9'", - "version": "==2.9.1" + "markers": "python_version >= '3.10'", + "version": "==2.14.2" }, "pygments": { "hashes": [ - "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", - "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c" + "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", + "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" ], - "markers": "python_version >= '3.8'", - "version": "==2.19.1" + "markers": "python_version >= '3.9'", + "version": "==2.20.0" }, "pyinstaller": { "hashes": [ - "sha256:38911feec2c5e215e5159a7e66fdb12400168bd116143b54a8a7a37f08733456", - "sha256:589937548d34978c568cfdc39f31cf386f45202bc27fdb8facb989c79dfb4c02", - "sha256:80c568848529635aa7ca46d8d525f68486d53e03f68b7bb5eba2c88d742e302c", - "sha256:8d4296236b85aae570379488c2da833b28828b17c57c2cc21fccd7e3811fe372", - "sha256:92efcf2f09e78f07b568c5cb7ed48c9940f5dad627af4b49bede6320fab2a06e", - "sha256:92fe7337c5aa08d42b38d7a79614492cb571489f2cb0a8f91dc9ef9ccbe01ed3", - "sha256:9f82f113c463f012faa0e323d952ca30a6f922685d9636e754bd3a256c7ed200", - "sha256:aa404f0b02cd57948098055e76ee190b8e65ccf7a2a3f048e5000f668317069f", - "sha256:b7260832f7501ba1d2ce1834d4cddc0f2b94315282bc89c59333433715015447", - "sha256:bc09795f5954135dd4486c1535650958c8218acb954f43860e4b05fb515a21c0", - "sha256:d9f21d56ca2443aa6a1e255e7ad285c76453893a454105abe1b4d45e92bb9a20", - "sha256:db0e7945ebe276f604eb7c36e536479556ab32853412095e19172a5ec8fca1c5" + "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", + "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", + "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", + "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", + "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", + "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", + "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", + "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", + "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", + "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", + "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", + "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025" ], "index": "pypi", - "markers": "python_version < '3.14' and python_version >= '3.8'", - "version": "==6.13.0" + "markers": "python_version < '3.16' and python_version >= '3.8'", + "version": "==6.21.0" }, "pyinstaller-hooks-contrib": { "hashes": [ - "sha256:5ce1afd1997b03e70f546207031cfdf2782030aabacc102190677059e2856446", - "sha256:6c2d73269b4c484eb40051fc1acee0beb113c2cfb3b37437b8394faae6f0d072" + "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", + "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3" ], "markers": "python_version >= '3.8'", - "version": "==2025.4" + "version": "==2026.6" }, - "python-dotenv": { + "pyjwt": { + "extras": [ + "crypto" + ], "hashes": [ - "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", - "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d" + "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", + "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" ], "markers": "python_version >= '3.9'", - "version": "==1.1.0" + "version": "==2.13.0" + }, + "python-dotenv": { + "hashes": [ + "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", + "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" + ], + "markers": "python_version >= '3.10'", + "version": "==1.2.2" }, "python-multipart": { "hashes": [ - "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", - "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13" + "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", + "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23" ], - "markers": "python_version >= '3.8'", - "version": "==0.0.20" + "markers": "python_version >= '3.10'", + "version": "==0.0.32" }, - "rich": { + "referencing": { "hashes": [ - "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", - "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725" + "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", + "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" ], - "markers": "python_full_version >= '3.8.0'", - "version": "==14.0.0" + "markers": "python_version >= '3.10'", + "version": "==0.37.0" + }, + "rich": { + "hashes": [ + "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", + "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" + ], + "markers": "python_full_version >= '3.9.0'", + "version": "==15.0.0" + }, + "rpds-py": { + "hashes": [ + "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", + "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", + "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", + "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", + "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", + "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", + "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", + "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", + "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", + "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", + "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", + "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", + "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", + "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", + "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", + "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", + "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", + "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", + "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", + "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", + "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", + "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", + "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", + "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", + "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", + "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", + "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", + "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", + "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", + "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", + "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", + "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", + "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", + "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", + "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", + "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", + "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", + "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", + "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", + "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", + "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", + "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", + "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", + "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", + "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", + "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", + "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", + "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", + "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", + "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", + "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", + "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", + "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", + "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", + "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", + "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", + "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", + "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", + "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", + "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", + "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", + "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", + "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", + "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", + "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", + "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", + "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", + "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", + "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", + "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", + "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", + "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", + "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", + "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", + "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", + "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", + "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", + "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", + "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", + "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", + "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", + "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", + "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", + "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", + "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", + "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", + "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", + "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", + "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", + "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", + "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", + "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", + "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", + "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", + "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", + "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", + "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", + "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", + "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", + "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", + "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", + "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", + "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", + "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", + "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", + "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", + "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", + "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", + "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", + "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", + "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", + "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", + "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", + "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", + "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", + "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef" + ], + "markers": "python_version >= '3.11'", + "version": "==2026.6.3" }, "setuptools": { "hashes": [ - "sha256:5a78f61820bc088c8e4add52932ae6b8cf423da2aff268c23f813cfbb13b4006", - "sha256:6cdc8cb9a7d590b237dbe4493614a9b75d0559b888047c1f67d49ba50fc3edb2" + "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", + "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3" ], - "markers": "python_version >= '3.9'", - "version": "==80.4.0" + "markers": "python_version >= '3.10'", + "version": "==83.0.0" }, "shellingham": { "hashes": [ @@ -363,29 +723,21 @@ "markers": "python_version >= '3.7'", "version": "==1.5.4" }, - "sniffio": { - "hashes": [ - "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", - "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" - ], - "markers": "python_version >= '3.7'", - "version": "==1.3.1" - }, "sse-starlette": { "hashes": [ - "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", - "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8" + "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", + "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627" ], - "markers": "python_version >= '3.9'", - "version": "==2.3.5" + "markers": "python_version >= '3.10'", + "version": "==3.4.6" }, "starlette": { "hashes": [ - "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", - "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5" + "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", + "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6" ], - "markers": "python_version >= '3.9'", - "version": "==0.46.2" + "markers": "python_version >= '3.10'", + "version": "==1.3.1" }, "sympy": { "hashes": [ @@ -398,111 +750,209 @@ }, "typer": { "hashes": [ - "sha256:818873625d0569653438316567861899f7e9972f2e6e0c16dab608345ced713c", - "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd" + "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", + "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1" ], - "markers": "python_version >= '3.7'", - "version": "==0.15.3" + "markers": "python_version >= '3.10'", + "version": "==0.27.0" }, "typing-extensions": { "hashes": [ - "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", - "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef" + "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", + "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" ], - "markers": "python_version >= '3.8'", - "version": "==4.13.2" + "markers": "python_version >= '3.9'", + "version": "==4.16.0" }, "typing-inspection": { "hashes": [ - "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", - "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122" + "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", + "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" ], "markers": "python_version >= '3.9'", - "version": "==0.4.0" + "version": "==0.4.2" }, "uvicorn": { "hashes": [ - "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", - "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403" + "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", + "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a" ], - "markers": "python_version >= '3.9'", - "version": "==0.34.2" + "markers": "python_version >= '3.10'", + "version": "==0.52.1" }, "websockets": { "hashes": [ - "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", - "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", - "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", - "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", - "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", - "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", - "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", - "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", - "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", - "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", - "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", - "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", - "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", - "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", - "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", - "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", - "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", - "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", - "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", - "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", - "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", - "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", - "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", - "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", - "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", - "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", - "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", - "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", - "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", - "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", - "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", - "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", - "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", - "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", - "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", - "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", - "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", - "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", - "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", - "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", - "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", - "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", - "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", - "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", - "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", - "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", - "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", - "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", - "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", - "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", - "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", - "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", - "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", - "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", - "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", - "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", - "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", - "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", - "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", - "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", - "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", - "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", - "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", - "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", - "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", - "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", - "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", - "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", - "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7" + "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", + "sha256:02f0b037a737d0cb0c33866c97bcd1a0b73170dfbf42d69d8fb86f51002fd5ae", + "sha256:038cfad5d5417f8bb09295abe986029a26d22f34bda622ccc79b670efd4dab56", + "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", + "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", + "sha256:0b52c76b8a870b141b7ca0705289452183ce7a523101954ccfe29a25986a673f", + "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", + "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", + "sha256:15920057a6b723f84734f0641403bca163a4b176e5af809ee4f0c4a1e75e9fed", + "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", + "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", + "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", + "sha256:1bdd8c4be420905dd732e00dcd669852d8128cc723efa585a0c0e51adb00a28a", + "sha256:1d4cf7e8e5b8b1fa40758ac7524843a00237b124ab217e227542cafcfeb7a946", + "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", + "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", + "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", + "sha256:2437d4ca208cc0f246d3a2297ae7474b4ba18261aaf5b9c79c84c031ecf348e1", + "sha256:246927ae9ae06ca0d42a483a4bdb80d4862e1ee5b4cab37c354a5e1ad8356448", + "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", + "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", + "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", + "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", + "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", + "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", + "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", + "sha256:2fa2cb465a131c347ba6717a78c887746e73edb1c131d01c982d6ef0d68b82e0", + "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", + "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", + "sha256:49266e4488309b38783257293a38298942b9a03aa106fcb45195377a77c0c1e2", + "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", + "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", + "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", + "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", + "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", + "sha256:5508f38c98ac29def9e747b87543b008a58b075df6da70b2cf2e0b47073d33bb", + "sha256:55383d8177b3c99fd873ee5db0e0193f4c1dd4a3feaccf1a4a03c1b7cf539cac", + "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", + "sha256:5661f868ef191d33dfc6a0cc7c5b3d495f0cc8bb3f8b30d87bda8755c61c95f5", + "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", + "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", + "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", + "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", + "sha256:6740be6d1bab69f08ab52cb15b08f76c143b6fe61c580ba62bd929f3ab7a1d42", + "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", + "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", + "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", + "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", + "sha256:70d438268e49f1a4bd096b6b6f7010f3ab48b5db2574dbf7d8c864c46ce7a06a", + "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", + "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", + "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", + "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", + "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", + "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", + "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", + "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", + "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", + "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", + "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", + "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", + "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", + "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", + "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", + "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", + "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", + "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", + "sha256:a39ce3a7b0e6059be093213d637963101380157bcbad355916738fafb490698d", + "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", + "sha256:a68e604c6d1b0338e46652e2688cbce8096ad9c03548b075fda9e2ea19a9b7dd", + "sha256:a8af570fc29cd998a921c7131c8ac81d9434466d6d25300cb12a690fb56a8a08", + "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", + "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", + "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", + "sha256:afbce6e3f0fac32dc87c2a0d84869d1a706460d64f39f3889386413e6e4d3d26", + "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", + "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", + "sha256:b85b960a4507b0714c0a1246d031be9118d908ee974dc085257297a955205f1d", + "sha256:b98860aefbd3d9bc8e3c7f0eefb83b11142b16110739c68cd33d3b4d6e84e536", + "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", + "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", + "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", + "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", + "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", + "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", + "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", + "sha256:c356dbddab0a529ed7574f78f559d75a223735c321c28f6f587fbf02b11ed301", + "sha256:c38515cb54902f7e97d0239e81ef46c4444f9475f4807fb9bbdb789b4089abcf", + "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", + "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", + "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", + "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", + "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", + "sha256:d41e9845514754a42d1d83b2fca9d27fee2ca7b3b0bee6843ba5a9bb2b6e25ac", + "sha256:d69fd559f9f0e8a52d2fce6f04ee143f86e70df0a189cd95164eddac599e810f", + "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", + "sha256:d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1", + "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", + "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", + "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", + "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", + "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", + "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", + "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", + "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", + "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", + "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", + "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f" + ], + "markers": "python_version >= '3.11'", + "version": "==17.0.1" + } + }, + "develop": { + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, + "packaging": { + "hashes": [ + "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", + "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" + ], + "markers": "python_version >= '3.8'", + "version": "==26.2" + }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" ], "markers": "python_version >= '3.9'", - "version": "==15.0.1" + "version": "==1.6.0" + }, + "pygments": { + "hashes": [ + "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", + "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" + ], + "markers": "python_version >= '3.9'", + "version": "==2.20.0" + }, + "pytest": { + "hashes": [ + "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", + "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==9.1.1" + }, + "pytest-asyncio": { + "hashes": [ + "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", + "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==1.4.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", + "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" + ], + "markers": "python_version >= '3.9'", + "version": "==4.16.0" } - }, - "develop": {} + } } diff --git a/README.md b/README.md index b99e733..83ad010 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ pipenv run stdio-server Or alternatively: ```bash -fastmcp run src/server.py +fastmcp run server.py ``` This will start the MCP server and listen for calls over `stdio`. @@ -83,15 +83,26 @@ pipenv run http-server Or alternatively: ```bash -fastmcp run src/server.py --transport sse --host 0.0.0.0 --port 4242 +fastmcp run server.py --transport sse --host 127.0.0.1 --port 4242 ``` -The server will start on `http://0.0.0.0:4242`. +The server will start on `http://127.0.0.1:4242`. + +> [!WARNING] +> The SSE transport has **no authentication**. Keep it bound to loopback; do not expose it to a network without putting authentication in front of it. + ### Using with Claude Desktop TBD, since I refuse to spend $100 every month on a Claude Max plan _just_ to test support for adding HTTP-based MCP servers. We'll wait for our friends to Anthropic to release this in GA. +### Running the Tests + +```bash +pipenv install --dev +pipenv run test +``` + ### Testing the Server If you have `npx` available, you can use the MCP Inspector: @@ -103,7 +114,7 @@ pipenv run inspector Alternatively: ```bash -npx @modelcontextprotocol/inspector fastmcp run src/server.py +npx @modelcontextprotocol/inspector fastmcp run server.py ``` This will allow you to access the MCP Inspector at `http://127.0.0.1:6274`, where you can list available tools, and run sample calls against them. @@ -123,74 +134,108 @@ The server exposes a tool called `solve_linear_equation` that can be used to sol #### Output Format +Responses are tagged with an `outcome` — one of `solved`, `no_solution`, `infinite_solutions` or `error`. See [`docs/linear-equations.md`](docs/linear-equations.md) for the full contract. + ```json { + "outcome": "solved", "solution": { "variable": "x", - "value": 2.0 + "value": "2", + "float_value": 2.0 }, "steps": [ "Original equation: 2*x + 3 = 7", "Equation in symbolic form: 2*x + 3 = 7", "Subtract 3 from both sides: 2*x = 4", - "Divide both sides by 2: x = 2.0", - "Solution: x = 2.0" + "Divide both sides by 2: x = 2", + "Solution: x = 2" ] } ``` +`solution.value` is the **exact** result as a string, so `1/3` stays `1/3` instead of being rounded. `float_value` carries the decimal approximation alongside it, and is `null` for complex or symbolic results. + ## Examples -### Example 1: Simple Linear Equation +### Example 1: Linear Equation with Negative Coefficient Input: ```json { - "equation": "2*x + 3 = 7", - "variable": "x" + "equation": "-3*y + 5 = 8", + "variable": "y" } ``` Output: ```json { + "outcome": "solved", "solution": { - "variable": "x", - "value": 2.0 + "variable": "y", + "value": "-1", + "float_value": -1.0 }, "steps": [ - "Original equation: 2*x + 3 = 7", - "Equation in symbolic form: 2*x + 3 = 7", - "Subtract 3 from both sides: 2*x = 4", - "Divide both sides by 2: x = 2.0", - "Solution: x = 2.0" + "Original equation: -3*y + 5 = 8", + "Equation in symbolic form: 5 - 3*y = 8", + "Subtract 5 from both sides: -3*y = 3", + "Divide both sides by -3: y = -1", + "Solution: y = -1" ] } ``` -### Example 2: Linear Equation with Negative Coefficient +### Example 2: An Exact Rational Result Input: ```json { - "equation": "-3*y + 5 = 8", - "variable": "y" + "equation": "3*x = 1", + "variable": "x" } ``` Output: ```json { + "outcome": "solved", "solution": { - "variable": "y", - "value": -1.0 + "variable": "x", + "value": "1/3", + "float_value": 0.3333333333333333 }, "steps": [ - "Original equation: -3*y + 5 = 8", - "Equation in symbolic form: -3*y + 5 = 8", - "Subtract 5 from both sides: -3*y = 3", - "Divide both sides by -3: y = -1.0", - "Solution: y = -1.0" + "Original equation: 3*x = 1", + "Equation in symbolic form: 3*x = 1", + "Divide both sides by 3: x = 1/3", + "Solution: x = 1/3 (≈ 0.3333333333333333)" + ] +} +``` + +### Example 3: Refusing a Non-Linear Equation + +The tool answers linear equations or says why it can't. It will not return one root of a quadratic as though it were the answer. + +Input: +```json +{ + "equation": "x**2 + x = 4", + "variable": "x" +} +``` + +Output: +```json +{ + "outcome": "error", + "error": "not_linear", + "message": "This equation is of degree 2 in x; only linear equations are supported.", + "steps": [ + "Original equation: x**2 + x = 4", + "Equation in symbolic form: x**2 + x = 4" ] } ``` diff --git a/docs/linear-equations.md b/docs/linear-equations.md index 67f9c8e..615fbc2 100644 --- a/docs/linear-equations.md +++ b/docs/linear-equations.md @@ -1,6 +1,6 @@ # Linear Equations -The server exposes a tool called `solve_linear_equation` that solves a linear equation in one variable and returns the solution together with a step-by-step derivation. +The server exposes a tool called `solve_linear_equation` that solves a linear equation in one variable and returns the exact solution together with a step-by-step derivation. ## Tool: `solve_linear_equation` @@ -8,8 +8,8 @@ The server exposes a tool called `solve_linear_equation` that solves a linear eq | Field | Type | Description | |-------|------|-------------| -| `equation` | string | The equation to solve, e.g. `"2*x + 3 = 7"`. Must contain a single `=`. | -| `variable` | string | The variable to solve for, e.g. `"x"`. | +| `equation` | string | The equation to solve, e.g. `"2*x + 3 = 7"`. Must contain a single `=`, and is limited to 500 characters. | +| `variable` | string | The variable to solve for, e.g. `"x"`. Must be an identifier (`[A-Za-z][A-Za-z0-9_]*`). | ```json { @@ -18,37 +18,93 @@ The server exposes a tool called `solve_linear_equation` that solves a linear eq } ``` +`x^2` (caret as exponent), `2x` (implicit multiplication) and `==` are all accepted and normalized, since LLMs commonly emit them. + ### Output +Every response carries an `outcome` field that says which of four shapes it is. Branch on `outcome`, not on the prose. + +| `outcome` | Meaning | Extra fields | +|-----------|---------|--------------| +| `solved` | Exactly one solution, verified by substituting it back into the original equation | `solution` | +| `no_solution` | The equation is inconsistent — no value satisfies it (`x = x + 1`) | — | +| `infinite_solutions` | The equation is an identity — every value satisfies it (`x = x`) | — | +| `error` | The equation could not be solved | `error`, `message` | + +All four carry `steps`. + ```json { + "outcome": "solved", "solution": { "variable": "x", - "value": 2.0 + "value": "2", + "float_value": 2.0 }, "steps": [ "Original equation: 2*x + 3 = 7", "Equation in symbolic form: 2*x + 3 = 7", "Subtract 3 from both sides: 2*x = 4", - "Divide both sides by 2: x = 2.0", - "Solution: x = 2.0" + "Divide both sides by 2: x = 2", + "Solution: x = 2" ] } ``` -- `solution.variable` — the variable that was solved for. -- `solution.value` — the numeric solution. -- `steps` — an ordered, human-readable derivation. +- `solution.value` — the **exact** solution as a string (`"1/3"`, `"sqrt(2)/2"`, `"-I"`). It is not rounded. +- `solution.float_value` — a decimal approximation, or `null` when the value is not a real number (complex or symbolic results). +- `steps` — an ordered, human-readable derivation. Each label describes the operation actually performed to produce the equation printed beside it. + +`error` responses carry a machine-readable `error` code: `parse_error`, `unsupported_input`, `not_linear`, `timeout`, or `internal_error`. + +### Exact values + +```json +// in: { "equation": "3*x = 1", "variable": "x" } +{ + "outcome": "solved", + "solution": { "variable": "x", "value": "1/3", "float_value": 0.3333333333333333 }, + "steps": [ + "Original equation: 3*x = 1", + "Equation in symbolic form: 3*x = 1", + "Divide both sides by 3: x = 1/3", + "Solution: x = 1/3 (≈ 0.3333333333333333)" + ] +} +``` + +### Refusing what it cannot answer + +```json +// in: { "equation": "x**2 + x = 4", "variable": "x" } +{ + "outcome": "error", + "error": "not_linear", + "message": "This equation is of degree 2 in x; only linear equations are supported.", + "steps": [ + "Original equation: x**2 + x = 4", + "Equation in symbolic form: x**2 + x = 4" + ] +} +``` ## Current limitations -These are tracked for improvement in `plans/PROJECT_PLAN.md` (Milestone 0 / 1): +Tracked in `plans/PROJECT_PLAN.md`: + +- **Linear only.** Any equation whose degree in the variable is not 1 is refused — including one that has a linear term (`x**2 + x = 4`). Quadratics are Milestone 2. +- **Non-polynomial input** (`sin(x) + x = 0`, `1/x = 2`) is refused the same way. +- **Symbolic parameters** (`a*x + b = c`) solve, and the derivation states the `a ≠ 0` assumption, but domain constraints are not yet reported as structured data. That is Milestone 4. + +## Notes on correctness + +Two separate checks stand behind every `solved` response: + +- **Substitution.** The solution is substituted back into the original equation and proven to satisfy it before it is returned. +- **Degree.** Substitution proves a value is *a* solution, not the *only* one — either root of `x**2 + x = 4` substitutes perfectly. So the degree of the equation is checked *before* solving, and anything that isn't linear is refused rather than answered partially. -- **Linear only.** A non-linear equation (coefficient of the variable is 0, e.g. `x**2 = 4`) fails. -- **Float coercion.** `value` is currently coerced to `float`, so exact results like `1/3` lose precision. Preserving exact SymPy values is planned. -- **No / infinite solutions** (e.g. `x = x + 1`, `x = x`) are not yet handled gracefully. -- **Step wording** for the "move the constant" step can be mislabeled; a rewrite is planned. +## Notes on untrusted input -## Notes +Equation strings are treated as hostile. They are normalized, checked against an allowlist of pure-arithmetic syntax, and only then evaluated; expression size, complexity and exponent magnitude are bounded, and each solve runs in a worker process under a wall-clock timeout. -The solver uses [SymPy](https://www.sympy.org/) under the hood — the equation is parsed, solved symbolically, and rendered into steps. See `solver.py` for the implementation. +If you run the SSE transport, bind it to loopback or put authentication in front of it. It has no authentication of its own. diff --git a/guard.py b/guard.py new file mode 100644 index 0000000..6c53f87 --- /dev/null +++ b/guard.py @@ -0,0 +1,210 @@ +""" +Safe parsing of untrusted equation strings. + +`sympy.parsing.sympy_parser.parse_expr` compiles its input and hands it to +`eval()`. Attribute access and subscripting survive that pipeline untouched, so +an ordinary-looking "equation" reaches the Python object graph and runs +arbitrary code:: + + sqrt.__globals__["__builtins__"]["__import__"]("os").system(...) + +Equation strings here come from an LLM, i.e. ultimately from an end user, and +the SSE transport binds `0.0.0.0:4242` without auth, so every input is hostile +until proven otherwise. + +Restricting the parser namespace does *not* close this: the escape hatch is +attribute traversal off whatever SymPy object is reachable, not name +resolution. `local_dict`, `global_dict={}`, and expression-size caps were all +measured to leave the payload above working. + +What does close it is inspecting the code SymPy is about to evaluate. This +module reuses SymPy's own tokenizer (`stringify_expr`) so that LLM-idiomatic +syntax is normalized first, walks the resulting AST against an allowlist, and +only then evaluates. The order matters: an allowlist applied to the raw string +would reject `x^2` (a `BitXor` node) and `2x` (a `SyntaxError`) — the very +syntax normalization exists to accept. + + normalize (stringify_expr) -> AST allowlist -> eval + +The allowlist is a whitelist, not a blacklist: any node type not named here is +rejected, so new SymPy syntax cannot silently widen the surface. +""" + +from __future__ import annotations + +import ast +from tokenize import TokenError +from typing import Dict + +import sympy as sp +from sympy.parsing.sympy_parser import ( + convert_xor, + eval_expr, + implicit_multiplication, + standard_transformations, + stringify_expr, +) + +#: Longest accepted raw equation string. Cheap first line of defence — the +#: parser is bounded independently, this just keeps obvious junk out. +MAX_EQUATION_LENGTH = 500 + +#: Cap on AST nodes in one side of an equation, to bound parse work. +MAX_AST_NODES = 250 + +#: Largest statically-known integer exponent. `x**2` is ordinary; `10**500` is +#: a memory bomb. Towers (`10**10**8`) are rejected outright regardless of +#: magnitude — see `_check_pow`. +MAX_EXPONENT = 1000 + +#: SymPy callables an equation may reference. Deliberately small: this is the +#: whole vocabulary of the linear solver, and every name here is one more +#: object whose attributes the AST allowlist has to keep out of reach. +_ALLOWED_FUNCTIONS = { + "sqrt", "exp", "log", "ln", "Abs", + "sin", "cos", "tan", "asin", "acos", "atan", + "sinh", "cosh", "tanh", +} + +#: Constructors SymPy's tokenizer emits itself (`2` becomes `Integer (2 )`). +#: These are not user vocabulary but they must be callable for eval to work. +_PARSER_CONSTRUCTORS = {"Integer", "Float", "Rational", "Symbol"} + +_ALLOWED_CALLS = _ALLOWED_FUNCTIONS | _PARSER_CONSTRUCTORS + +#: Namespace handed to eval. Restricting it is not what makes this safe (see +#: the module docstring) but there is no reason to expose more than is used. +GLOBAL_DICT: Dict[str, object] = { + name: getattr(sp, name) for name in sorted(_ALLOWED_CALLS) +} +GLOBAL_DICT.update({"pi": sp.pi, "E": sp.E, "I": sp.I}) + +TRANSFORMATIONS = standard_transformations + ( + convert_xor, # x^2 -> x**2 + implicit_multiplication, # 2x -> 2*x +) +# Deliberately *not* `implicit_multiplication_application`, which bundles +# `split_symbols`: that rewrites an unrecognised name into a product of its +# letters, so `factorial(9)` silently becomes `9*a**2*c*f*i*l*o*r*t` and gets +# solved as if the user had asked for that. Quietly answering a different +# question is the failure mode this project exists to avoid. With plain +# `implicit_multiplication` an unknown function name fails to resolve and is +# reported as a parse error instead. + +#: Expression nodes that pure arithmetic needs. Anything absent is rejected — +#: notably `Attribute`, `Subscript`, `Lambda`, comprehensions and `Starred`, +#: which are the routes out of the sandbox. +_ALLOWED_NODES = ( + ast.Expression, + ast.BinOp, ast.UnaryOp, ast.Call, + ast.Constant, ast.Name, ast.Load, + ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.Mod, + ast.USub, ast.UAdd, +) + + +class UnsafeExpressionError(ValueError): + """The input reached for something outside plain arithmetic.""" + + +class EquationSyntaxError(ValueError): + """The input is not a parseable expression.""" + + +def _check_pow(node: ast.BinOp) -> None: + """Reject exponent towers and oversized statically-known exponents. + + `10**10**8` is pure arithmetic — the allowlist passes it — and evaluating + it exhausts memory. Bounding the exponent here is what makes the AST guard + a real limit rather than a syntax filter; the wall-clock timeout in + `sandbox.py` is the backstop for everything that slips past. + """ + exponent = node.right + for child in ast.walk(exponent): + if isinstance(child, ast.BinOp) and isinstance(child.op, ast.Pow): + raise UnsafeExpressionError("nested exponents are not supported") + + value = _static_int(exponent) + if value is not None and abs(value) > MAX_EXPONENT: + raise UnsafeExpressionError( + f"exponent {value} exceeds the maximum of {MAX_EXPONENT}" + ) + + +def _static_int(node: ast.AST) -> int | None: + """Integer value of a constant-only subtree, or None if it isn't one. + + Handles both a bare literal and the `Integer (10 )` form SymPy's tokenizer + produces, plus unary minus. + """ + if isinstance(node, ast.Constant) and isinstance(node.value, int): + return node.value + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): + inner = _static_int(node.operand) + if inner is None: + return None + return -inner if isinstance(node.op, ast.USub) else inner + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "Integer" + and len(node.args) == 1 + ): + return _static_int(node.args[0]) + return None + + +def assert_safe(code: str) -> None: + """Raise unless `code` is pure arithmetic over allowlisted names. + + `code` is the *transformed* source SymPy is about to evaluate, not the + user's raw string. + """ + try: + tree = ast.parse(code, mode="eval") + except SyntaxError as exc: + raise EquationSyntaxError("could not parse the expression") from exc + + nodes = list(ast.walk(tree)) + if len(nodes) > MAX_AST_NODES: + raise UnsafeExpressionError("expression is too complex") + + for node in nodes: + if not isinstance(node, _ALLOWED_NODES): + raise UnsafeExpressionError( + f"unsupported syntax in expression ({type(node).__name__})" + ) + if isinstance(node, ast.Call): + if node.keywords: + raise UnsafeExpressionError("keyword arguments are not supported") + if not isinstance(node.func, ast.Name) or node.func.id not in _ALLOWED_CALLS: + raise UnsafeExpressionError("unsupported function call") + elif isinstance(node, ast.Constant): + if not isinstance(node.value, (int, float, str)): + raise UnsafeExpressionError("unsupported literal") + elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Pow): + _check_pow(node) + + +def safe_parse(expression: str, local_dict: Dict[str, sp.Symbol]) -> sp.Expr: + """Parse one side of an equation, refusing anything that isn't arithmetic. + + Normalizes LLM-idiomatic syntax (`x^2`, `2x`) on the way through, since the + allowlist has to run against normalized code to accept it. + """ + if not expression.strip(): + raise EquationSyntaxError("expression is empty") + + try: + code = stringify_expr(expression, local_dict, GLOBAL_DICT, TRANSFORMATIONS) + except (SyntaxError, TokenError) as exc: + raise EquationSyntaxError("could not parse the expression") from exc + + assert_safe(code) + + try: + return eval_expr(code, local_dict, GLOBAL_DICT) + except (UnsafeExpressionError, EquationSyntaxError): + raise + except Exception as exc: # SymPy raises a wide range here + raise EquationSyntaxError("could not parse the expression") from exc diff --git a/plans/PROJECT_PLAN.md b/plans/PROJECT_PLAN.md index 140080b..3af0616 100644 --- a/plans/PROJECT_PLAN.md +++ b/plans/PROJECT_PLAN.md @@ -2,13 +2,21 @@ > Sisyphus plan. Lives in the repo on purpose (see `.gitignore` un-ignore rule). Update it as milestones land; check boxes as tasks complete. > -> **Status:** reviewed by the `momus` plan-review agent and revised. Its two Critical findings — (1) parsing DoS / untrusted-input security was entirely absent, and (2) a sequencing contradiction where the "preserve exact values" guardrail needed a schema change that wasn't scheduled until M1 — are both resolved (security folded into M0; the schema change pulled forward into M0). Should-fix items (broken `task build-release`, CI dev-deps gap, Windows `.exe` handling, prerelease tag gating, version-source drift, LLM math syntax) are folded into their milestones. +> **Status:** reviewed twice by the `momus` plan-review agent and revised. +> +> *Round 1* raised two Critical findings — (1) parsing DoS / untrusted-input security was entirely absent, and (2) a sequencing contradiction where the "preserve exact values" guardrail needed a schema change that wasn't scheduled until M1. Both were resolved (security folded into M0; the schema change pulled forward into M0). +> +> **M0 is implemented** (see `guard.py`, `sandbox.py`, `solver.py`, `tests/`). The code-execution vulnerability, the silent non-linear wrong answers, the float coercion, the opaque `IndexError`s and the mislabelled steps are all fixed and covered by tests. Two things surfaced during implementation and are recorded in M0 below: re-locking `Pipfile.lock` silently upgraded fastmcp across a major version (now pinned), and the first draft of the timeout charged worker startup against the request budget. +> +> *Round 2* re-ran every factual claim in this plan against the code. Three Critical findings: (1) the M0 security item **understated the vulnerability** — `parse_expr` on untrusted input is remote *code execution*, not merely DoS, and the remediation this plan prescribed was verified not to work; (2) those security tasks **conflict with the LLM-syntax task** and must be ordered, not just listed; (3) the plan (and `CLAUDE.md`, and `docs/`) assert that non-linear input raises — **verified false**, it silently returns one root of a polynomial. Two claims in this plan were also **factually wrong** (the `sqrt(2)` crash, the `y = -1` example output) and have been corrected — the acceptance criteria built on them were testing failures that do not exist. Findings 5–12 are folded into their milestones below. ## 1. What this project is EquationsCP is a [FastMCP](https://github.com/jlowin/fastmcp) server that gives LLMs exact symbolic mathematics via [SymPy](https://www.sympy.org/). Instead of an LLM guessing arithmetic, it calls a tool and gets back a correct solution plus a human-readable, step-by-step derivation. It ships as standalone PyInstaller binaries. Docs live **in this repo** under `docs/` (canonical source of truth). A separate public documentation website (`equationscp-site`, a Next.js/Netlify project) lives in its own repo *outside* this one and may mirror `docs/` later — it is not edited as part of this plan. -**Current state (v0.1.0):** one tool, `solve_linear_equation`. stdio (default) + SSE transports. PyInstaller binaries built by CI on tag push (linux-amd64/arm64, darwin-arm64) as GitHub prereleases. **No automated tests.** Step-generation logic in `solver.py` is hand-rolled and fragile. +**Current state (post-M0, unreleased):** one tool, `solve_linear_equation`. stdio (default) + SSE transports. PyInstaller binaries built by CI on tag push (linux-amd64/arm64, darwin-arm64) as GitHub prereleases. Test suite runs on every push and PR. Input is parsed behind an AST allowlist and solved in a worker process under a wall-clock timeout; output is a discriminated union; answers are checked for degree before solving and verified by substitution after. + +> **Every *published* v0.1.0 binary predates this and executes arbitrary Python from the `equation` field.** They are still on the releases page. Cutting a replacement release is tracked in M6. ## 2. Vision & success criteria @@ -23,50 +31,68 @@ Project-level success: ## 3. Architectural guardrails (apply to every milestone) - **Correctness comes from SymPy, not from string assembly.** Solve symbolically, then *verify* by substituting back before returning. Only render steps for values already proven correct. -- **Untrusted input is the default.** Equation strings originate from an LLM, i.e. ultimately from an end user, and `http-server` binds `0.0.0.0:4242` with no auth. Every solver must treat input as hostile: bounded parsing, size/complexity limits, and a wall-clock timeout (see M0 for the concrete mechanism). +- **Verification must cover completeness, not just soundness.** Back-substitution proves a returned root *satisfies* the equation; it does **not** prove it is the only one, or that the equation was of the type the tool claims to handle. Before M0, `x**2 + x = 4` returned `1.5615…`, which back-substitutes perfectly while silently discarding the second root. Every solver must therefore assert the **degree/shape** of the input before solving, and return *all* solutions or a typed refusal. +- **Untrusted input is the default.** Equation strings originate from an LLM, i.e. ultimately from an end user, and the SSE transport has no auth. `parse_expr` `eval()`s its transformed input, so before M0 a short payload reached the Python object graph and ran arbitrary code. Every solver must go through `guard.safe_parse` — an AST-level allowlist over normalized code, with size, complexity and exponent limits — and run under the wall-clock timeout in `sandbox.py`. Never call `parse_expr`, `sympify` or `eval` on caller input directly. - **One pattern per new capability:** new fn in `solver.py` (or a `solvers/` package once it grows) + schemas in `schemas.py` + `@server.tool` in `server.py` + a doc page in `docs/` + tests. -- **Don't silently coerce.** Today `value` is forced to `float` (`schemas.py` `Solution.value: float`), which corrupts exact answers (`x = 1/3`) and outright raises `ValidationError` on symbolic/complex ones (`x = sqrt(2)`). Return exact forms; offer a float alongside, don't replace. *(This requires a schema change — sequenced into M0, not M1, because the guardrail is unachievable while the schema forces a float.)* -- **Fail loudly and structurally.** Distinguish "no solution", "infinitely many", "not linear", "parse error" as typed outcomes, not bare `ValueError` strings. -- **Tool-contract changes are versioned.** Any change to an `EquationOutput` shape (e.g. the M1 discriminated union) is a breaking change to the MCP tool schema. Bump the version, note it in the changelog, and reconcile the two version sources (`Versionfile` and the hardcoded `version="0.1.0"` in `server.py`). +- **Don't silently coerce.** `value` was forced to `float`, which corrupted exact answers (`3*x = 1` returned `0.3333333333333333`, not `1/3`) and raised `TypeError: Cannot convert complex to float` on complex ones (`I*x = 1`). Return exact forms; offer a float alongside, don't replace. *(Landed in M0: `Solution.value` is the exact string and `float_value` the optional approximation.)* + - *Correction from review round 2:* an earlier draft claimed `x = sqrt(2)` raises `ValidationError`. It does not — `2*x = sqrt(2)` returns `0.7071…` and the Pydantic model accepts it. The real failures are complex results (above) and **parametric ones**, which fail earlier and for a different reason: `solver.py`'s `if b > 0` step-generation raises `TypeError: cannot determine truth value of Relational` on `a*x + b = c`. Fixing the schema alone will not fix parametric input; the step generator must stop comparing symbolic quantities. +- **Fail loudly and structurally.** Distinguish "no solution", "infinitely many", "not linear", "parse error" as typed outcomes, not bare `ValueError` strings — and never surface raw Python exception text to the client. +- **Tool-contract changes are versioned — and batched.** Any change to an `EquationOutput` shape is a breaking change to the MCP tool schema. Bump the version, note it in the changelog, and reconcile the two version sources (`Versionfile` and the hardcoded `version="0.1.0"` in `server.py`). Because M0 and M1 each originally rewrote the output envelope, **both envelope changes now land together in M0** — clients get one break, not two. --- ## Milestone 0 — Foundations, hardening & security ⚙️ *(do this first)* -**Goal:** make the existing single tool trustworthy, *safe against hostile input*, and safe to build on. No new features until there's a test net. This milestone deliberately absorbs the schema change and input-security work that later milestones depend on. +**Goal:** make the existing single tool trustworthy, *safe against hostile input*, and safe to build on. No new features until there's a test net. This milestone deliberately absorbs the schema work and input-security work that later milestones depend on. **Testing** -- [ ] Add `pytest` (+ `pytest-asyncio`) to `[dev-packages]` in `Pipfile` (currently empty); add a `test` script; **re-lock and commit `Pipfile.lock`** (CI caches on it). -- [ ] Create `tests/` with unit tests for the linear solver, covering: basic (`2*x+3=7`), negative coefficients (`-3*y+5=8`), variables on both sides (`2*x+1 = x+4`), rational results (`3*x = 1` → exact `1/3`), no solution (`x = x+1`), infinitely many (`x = x`), non-linear (`x**2 = 4`), missing `=`, whitespace, unknown variable, and the hostile inputs below. -- [ ] Add an **end-to-end tool test** driving the in-process FastMCP `solve_linear_equation` tool and asserting the `EquationOutput` shape. -- [ ] Wire a **CI test job** that runs `pipenv install --dev` then `pipenv run test` on push/PR — note the existing build step uses bare `pipenv install`, which skips dev deps, so this needs its own job/step. +- [x] Add `pytest` (+ `pytest-asyncio`) to `[dev-packages]` in `Pipfile` (currently empty); add a `test` script; **re-lock and commit `Pipfile.lock`** (CI caches on it). +- [x] Create `tests/` with unit tests for the linear solver, covering: basic (`2*x+3=7`), negative coefficients (`-3*y+5=8`), variables on both sides (`2*x+1 = x+4`), rational results (`3*x = 1` → exact `1/3`), no solution (`x = x+1`), infinitely many (`x = x`), non-linear with **no** linear term (`x**2 = 4`), non-linear **with** a linear term (`x**2 + x = 4`, `x**3 - 2*x = 0` — see the correctness section; these are the cases that silently return a wrong answer today), complex results (`I*x = 1`), parametric input (`a*x + b = c`), missing `=`, multiple `=` (`2*x = 3 = 4`), whitespace, unknown variable (`2*x+3=7` solved for `z`), and every hostile input below. +- [x] **Assert on step text, not just values.** Every happy-path test pins the full `steps` list against expected strings. The derivation is the product; a test net that only checks `value` would pass today despite the mislabelled steps below. +- [x] Add an **end-to-end tool test** driving the in-process FastMCP `solve_linear_equation` tool and asserting the `EquationOutput` shape. +- [x] Wire a **CI test job** that runs `pipenv install --dev` then `pipenv run test` on push/PR — note the existing build step uses bare `pipenv install`, which skips dev deps, so this needs its own job/step. Installed as `.github/workflows/test-workflow.yml`; first green run confirmed on PR #2. **Correctness bug fixes (in `solver.py` / `schemas.py`)** -- [ ] **Exact values:** change `Solution.value` from `float` to accept exact SymPy output (string form + optional `float_value`). Stop unconditionally `float()`-ing at `solver.py` — that both corrupts `1/3` and raises on `sqrt(2)`. *(Pulled forward from M1 because the "preserve exact values" guardrail is impossible while the schema forces a float.)* -- [ ] **No/infinite solutions:** stop indexing `sp.solve(...)[0]` — `[]` for `x=x+1` (no solution) and `x=x` (infinite) currently throws `IndexError` surfaced as an opaque `"list index out of range"`. Return typed outcomes instead. -- [ ] **Incoherent steps:** the step narrative computes its verb/amount from the constant of `left−right`, disconnected from the symbolic equation shown one line earlier (the printed RHS value is correct; the *operation label* is not). Regenerate steps from the actual operation applied. -- [ ] **Back-substitution verify:** substitute the solution into the original equation and assert equality *before* returning; never return an unverified solution. -- [ ] **Modernize:** replace `asyncio.get_event_loop().run_in_executor(...)` in `server.py` with `asyncio.to_thread(...)` (the get_event_loop pattern is deprecated on 3.12). - -**Input security & robustness (CRITICAL — new, was missing)** -- [ ] **Bounded parsing:** `parse_expr` evaluates parsed input, so `10**10**10 = 0` or `factorial(10**8)=0` is an unbounded CPU/memory DoS. Add an expression size/complexity limit and parse with a restricted `local_dict`/namespace rather than the full SymPy global namespace. -- [ ] **Wall-clock timeout:** enforce a hard timeout on the solve. Note the current thread-pool executor **cannot be cancelled**, so a pathological input wedges the pool — use a cancellable mechanism (subprocess with timeout, or a signal/watchdog) and document the tradeoff. -- [ ] **Auth note for HTTP mode:** document that `http-server` binds `0.0.0.0:4242` with no auth; recommend binding to loopback or adding auth before exposing it. Track any hardening as a task here. -- [ ] **LLM-idiomatic syntax:** LLMs emit `x^2` (parses as XOR in SymPy), `2x` (implicit multiplication — fails), and sometimes `==` (breaks `split("=",1)`). Normalize input with `convert_xor` + `implicit_multiplication_application` transformations and handle `==`. Test each. - -**Acceptance:** `pipenv run test` is green and runs on every push/PR (with `--dev` deps); every listed edge case *and* each hostile input (oversized expression, timeout, `x^2`, `2x`, `==`) has a passing assertion; exact values (`1/3`, `sqrt(2)`) round-trip without coercion or crash; no solution is returned unverified; a solve that exceeds the limit fails fast with a typed error rather than hanging. +- [x] **Non-linear input is silently mis-solved (CRITICAL — was previously believed handled).** The `a == 0` guard only catches polynomials with *no* linear term. Verified: `x**2 + x = 4` returns `1.5615…` (one of two roots, the other silently dropped) and `x**3 - 2*x = 0` returns `0.0`, dropping `±sqrt(2)` — each with a fabricated linear narrative (`"Add 4 to both sides: 1*x = 4"`). An LLM receives a confidently wrong answer with no error. Replace the coefficient heuristic with an explicit degree check (`sp.Poly(...).degree()` / `sp.degree`) and refuse anything of degree ≠ 1 with a typed outcome. Note this bug is **invisible to back-substitution verification** — the returned root does satisfy the equation. Update `CLAUDE.md` and `docs/linear-equations.md`, which both repeat the incorrect "non-linear input raises `ValueError`" claim. +- [x] **Exact values:** change `Solution.value` from `float` to accept exact SymPy output (string form + optional `float_value`). Stop unconditionally `float()`-ing at `solver.py:76` — that corrupts `1/3` and raises on complex results. +- [x] **Typed outcomes + discriminated union (pulled forward from M1):** stop indexing `sp.solve(...)[0]` — `[]` for `x = x+1` (no solution), `x = x` (infinite), and an unknown variable all currently throw `IndexError` surfaced as an opaque `"list index out of range"`. Land the full `solved | no_solution | infinite_solutions | error` union here, together with the exact-value change, so the tool schema breaks **once**. +- [x] **Incoherent steps:** the step narrative computes its verb and amount from the constant of `left − right`, disconnected from the symbolic equation shown one line earlier. Verified: `2*x + 3 = 7` prints `"Add 4 to both sides: 2*x = 4"` (should be *subtract 3*); `2*x + 1 = x + 4` prints `"Add 3 to both sides: 1*x = 3"` — wrong verb, unreduced `1*x`, and the divide step is skipped entirely because `a == 1`. Also `"Equation in symbolic form"` renders SymPy's `repr` (`Eq(2*x + 3, 7)`), not the `2*x + 3 = 7` shown in the docs. Regenerate every step from the actual operation applied. +- [x] **Parametric input crashes in step generation:** `a*x + b = c` raises `TypeError: cannot determine truth value of Relational` at the `if b > 0` branch (`solver.py:67`), before any coercion. Stop branching on the sign of a possibly-symbolic quantity. (Full parametric support is M4; not crashing is M0.) +- [x] **Back-substitution verify:** substitute the solution into the original equation and assert equality *before* returning; never return an unverified solution. Pair with the degree check above — substitution alone is not sufficient. +- [x] **Modernize:** replace `asyncio.get_event_loop().run_in_executor(...)` in `server.py` with `asyncio.to_thread(...)` (the `get_event_loop` pattern is deprecated on 3.12). +- [x] **Quiet the root logger:** `server.py:17` calls `logging.basicConfig(level=logging.DEBUG)`, which sets the **root** logger — every dependency (fastmcp, anyio, httpx) emits DEBUG for the life of the process. Scope it to this module's logger and default to INFO/WARNING. Note `server.py:47` logs every user equation; decide deliberately whether that is acceptable. +- [x] **Stop leaking internals in errors:** `server.py:73` re-raises `f"Failed to solve equation: {str(e)}"`, forwarding raw Python `SyntaxError` fragments (and any future internal detail) to the client. Map to the typed error outcome with a safe message; log the detail server-side. + +**Input security (CRITICAL — remote code execution, not just DoS)** + +> Order matters here. Normalization must run **before** the AST allowlist, and the allowlist **before** `eval`. Doing the allowlist on the raw string rejects the very syntax the normalization task promises to support (`x^2` parses as `BitXor`; `2x` is a `SyntaxError`). Pipeline: **normalize → AST-guard → parse → solve**. + +- [x] **Arbitrary code execution via `parse_expr`.** `parse_expr` `eval()`s the transformed source, and attribute/subscript access passes straight through. Verified end-to-end: an `equation` value of `sqrt.__globals__["__builtins__"]["__import__"]("os").getpid()` executes and returns the server's PID inside `solution.value`. Because `http-server` binds `0.0.0.0:4242` with no auth, this is **pre-auth RCE** on any exposed instance, and it is reachable from any prompt an LLM can be talked into emitting. + - *The previously prescribed fix does not work and was verified failing.* Restricting `local_dict` alone — and even `global_dict={}` plus a curated `local_dict` — still executes the payload, because the escape hatch is attribute traversal off whatever SymPy object is in the namespace, not name resolution. A size/complexity limit is likewise useless: the payload is 60 characters. + - *What was verified to work:* parse the normalized string with `ast.parse(..., mode="eval")` and walk it against an allowlist before handing it to SymPy — permit only `BinOp`/`UnaryOp`/`Constant`/`Name`/`Load`/arithmetic operators and `Call` to a whitelisted set of SymPy function names; reject `Attribute`, `Subscript`, keyword arguments, and everything else. This blocks the payload above, the `().__class__.__bases__[0].__subclasses__()` family, and bare `x.__class__`, while `2*x + 3`, `sqrt(2)/2`, and `-3*y + 5` still parse normally. + - [x] Add a regression test that asserts the RCE payload is **rejected**, not merely that it returns something. The current M0 hostile-input list had no code-execution assertion at all. +- [x] **Wall-clock timeout (orthogonal to the allowlist — both are needed).** The AST allowlist permits pure arithmetic, so `10**10**8 + x = 0` still passes it and still hangs: verified running >25s with no return. Enforce a hard timeout. Note the current thread-pool executor **cannot be cancelled**, so a pathological input wedges the pool — use a cancellable mechanism (subprocess with timeout, or a signal/watchdog) and document the tradeoff. Add an input-length/expression-depth cap as cheap defence in depth. +- [x] **Auth for HTTP mode:** document that `http-server` binds `0.0.0.0:4242` with no auth; default the documented invocation to loopback and recommend auth before exposing it. Given the RCE above, treat this as a shipping blocker for the SSE transport, not a footnote. +- [x] **LLM-idiomatic syntax:** LLMs emit `x^2` (parses as XOR — verified `TypeError: unsupported operand type(s) for ^`), `2x` (implicit multiplication — verified `SyntaxError`), and sometimes `==` (verified `SyntaxError`, since `split("=", 1)` leaves a dangling `=`). Also reject multiple `=` (`2*x = 3 = 4`) explicitly. Normalize with `convert_xor` + `implicit_multiplication_application`, applied **before** the AST guard per the ordering note above. Test each. + +**Found while implementing M0 (recorded, both resolved)** +- [x] **Re-locking silently upgraded fastmcp across a major version.** Adding `[dev-packages]` forces a full re-resolve, and the unpinned `fastmcp = "*"` jumped 2.3.3 → 3.4.5 — a different API whose wheel resolves to a `fastmcp_slim` namespace package that does not export `Client`. The committed lock had always pinned 2.3.3; the Pipfile now says so explicitly. Upgrading to 3.x is its own piece of work, not a side effect of adding a test runner. +- [x] **The timeout must not charge worker startup to the request.** The first draft started the clock before the worker had imported SymPy, so a cold first request could time out at any sensible budget. The worker now sends a readiness handshake and the solve budget starts after it. +- [x] **`implicit_multiplication_application` silently rewrites unknown names.** It bundles `split_symbols`, which turns `factorial(9)` into `9*a**2*c*f*i*l*o*r*t` and solves *that* — quietly answering a different question, the exact failure this project exists to avoid. Replaced with plain `implicit_multiplication`, under which an unrecognised function name is reported as a parse error. `2x` and `x^2` still normalize. + +**Acceptance:** `pipenv run test` is green (74 tests) and runs on every push/PR (with `--dev` deps); every listed edge case and each hostile input has a passing assertion, **including an explicit assertion that the code-execution payload is rejected**; non-linear input with a linear term (`x**2 + x = 4`) returns a typed refusal or all roots — never one silent root; exact values (`1/3`) round-trip without coercion and complex results (`I*x = 1`) no longer crash; parametric input (`a*x + b = c`) fails typed rather than raising `TypeError`; every happy-path test pins the full step list, and no step label contradicts the operation it describes; no solution is returned unverified; a solve that exceeds the limit fails fast with a typed error rather than hanging. --- ## Milestone 1 — Solver architecture & richer linear support 📐 -**Goal:** refactor for many solvers and round out linear algebra of one variable. (The exact-value schema change and typed no/infinite outcomes already landed in M0; M1 generalizes the structure.) +**Goal:** refactor for many solvers. (The exact-value schema change, typed outcomes, *and* the discriminated union all landed in M0 — see the batching guardrail. M1 generalizes the structure without further breaking the contract.) - [ ] Promote `solver.py` → a `solvers/` package (`solvers/linear.py`, shared `solvers/_util.py` for parse + verify + step helpers). Keep import paths working with the PyInstaller spec (`server.spec`). -- [ ] Extract a reusable **parse → normalize → solve → verify → format** pipeline (including the M0 security limits) so every future solver reuses it. -- [ ] Formalize the discriminated result union in `schemas.py` (`solved` | `no_solution` | `infinite_solutions` | `error`) as the shared output shape for all tools. +- [ ] Extract a reusable **normalize → AST-guard → parse → degree-check → solve → verify → format** pipeline (carrying the M0 security limits and the completeness check) so every future solver reuses it rather than reimplementing the guard. +- [ ] Move the M0 union definition into the shared schema module as the output shape for all tools. -**Acceptance:** the linear tool's *happy-path answer* is unchanged **in value** (though the JSON envelope now carries a discriminated `outcome` field — this is a deliberate, versioned tool-schema change, not backward-compatible; bump the version accordingly); all outcome types are represented; docs page updated. +**Acceptance:** the linear tool's behaviour is unchanged from end-of-M0 — this milestone is a pure refactor and **must not** alter the tool schema again; every future solver goes through the shared pipeline, verified by a test that the guard is applied. --- @@ -74,23 +100,26 @@ Project-level success: **Goal:** second tool, `solve_quadratic_equation`. -- [ ] `solvers/quadratic.py`: solve `ax²+bx+c=0`, return both roots (real or complex), discriminant, and steps (standard form → discriminant → quadratic formula → roots). +> Depends on M0's degree check: until non-linear input is refused by the linear tool, `x**2 + x = 4` is answered (wrongly) by *both* tools and clients get inconsistent results depending on which the LLM picks. + +- [ ] `solvers/quadratic.py`: solve `ax²+bx+c=0`, return **both** roots (real or complex), discriminant, and steps (standard form → discriminant → quadratic formula → roots). - [ ] Schemas + `@server.tool` registration + tests (two real roots, double root, complex roots, degenerate/linear input). - [ ] Doc page `docs/quadratic-equations.md` mirroring the linear page. -**Acceptance:** tool solves the four canonical cases with verified roots; documented; tested. +**Acceptance:** tool solves the four canonical cases with every root verified by substitution and the root *count* asserted against the degree; documented; tested. --- ## Milestone 3 — Systems of linear equations 🔗 -**Goal:** `solve_linear_system` for N equations / N unknowns. +**Goal:** `solve_linear_system` for M equations in N unknowns (not only the square case — the underdetermined and inconsistent regimes are in scope and are tested below). - [ ] Accept a list of equations + list of variables; solve via SymPy `linsolve`. - [ ] Report unique / no / infinite solution sets; show elimination or matrix steps. - [ ] Schemas (list inputs), tool, tests (unique, inconsistent, underdetermined), doc page. +- [ ] Apply the M0 input guard to **every** element of the list, and bound the list length — N equations means N parse operations and N chances to hit the timeout. -**Acceptance:** 2×2 and 3×3 systems solved and verified; all three solution regimes handled. +**Acceptance:** 2×2 and 3×3 systems solved and verified; all three solution regimes handled; oversized/hostile list inputs rejected. --- @@ -98,11 +127,12 @@ Project-level success: **Goal:** solutions in terms of parameters, not just numbers. -- [ ] Allow symbolic constants (solve `a*x + b = c` for `x` → `x = (c-b)/a`). +- [ ] Allow symbolic constants (solve `a*x + b = c` for `x` → `x = (c-b)/a`). Note M0 only stops this crashing; M4 makes it work. - [ ] Preserve exact symbolic output; no float coercion; note domain constraints (e.g. `a ≠ 0`). +- [ ] Step generation must not branch on the sign or truth value of symbolic quantities (the M0 `TypeError`); render neutral phrasing when the sign is unknown. - [ ] Tests + doc page. -**Acceptance:** parametric linear/quadratic solves return exact symbolic expressions with stated constraints. +**Acceptance:** parametric linear/quadratic solves return exact symbolic expressions with stated constraints, and no code path compares a symbolic quantity with `>`/`<`. --- @@ -121,16 +151,21 @@ Project-level success: **Goal:** frictionless install everywhere; healthy release process. +- [ ] **Gate releases on green tests.** M0 installed `.github/workflows/test-workflow.yml`, which runs the suite on every push and PR. It does **not** run on tag pushes, so releases are still ungated: add `needs: test` on `build` in `build.yml`, or trigger the test job on tags too. *(Added in review round 2: this was an acceptance criterion with no task implementing it.)* +- [x] **Two CI runs per push.** `test-workflow.yml` triggered on both `push: branches: ['**']` and `pull_request`; for a branch in this repo both fire, so every push ran the suite twice. Fixed by narrowing `push` to `main`, which keeps PR coverage (including from forks) and adds a post-merge run. Verified: the first push under the new triggers produced exactly one run. +- [ ] **Remove `.github/workflows/README.md`.** It was carried over from the parked-workflow directory and still describes the job as awaiting installation. Actions ignores non-YAML files there, so this is stale text, not a broken workflow. - [ ] **Fix the already-broken `build-release` Taskfile target:** `Taskfile.yaml` reads `sh: cat version`, but the file is `Versionfile` (the sibling `build-release-candidate` target cats `Versionfile` correctly). This target is dead today — fix before documenting it. - [ ] **Reconcile version sources:** `server.py` hardcodes `version="0.1.0"` independently of `Versionfile`. Make one the source of truth (e.g. read `Versionfile` at build/runtime). - [ ] **Windows binaries** in `.github/workflows/build.yml`. Not a one-liner: add a Windows runner to the matrix, and handle that PyInstaller emits `server.exe` (the artifact `cp dist/server …` step assumes a Unix path + `server` name + a POSIX shell). Account for the `.exe` extension and cross-platform artifact naming. - [ ] **Gate prereleases correctly:** the workflow triggers on *all* tags (`tags: ['*']`) and hardcodes `prerelease: true`. To promote real releases, add tag-pattern logic that distinguishes `-rc-` tags from final `vX.Y.Z` tags and sets `prerelease` conditionally — not just flipping the flag. - [ ] **Replace deprecated CI syntax:** the `::set-output` calls in `build.yml` are deprecated GitHub Actions syntax; migrate to `$GITHUB_OUTPUT`. +- [ ] **Bump stale action versions** while in there: `actions/checkout@v3` and `actions/setup-python@v4` sit alongside `upload-artifact@v4`/`download-artifact@v4`; move the first two to v4/v5. - [ ] Investigate **macOS code signing / notarization** so users skip the Gatekeeper workaround. -- [ ] Document the **HTTP/SSE transport** path for MCP clients (currently "TBD" in README), including the auth caveat from M0. +- [ ] Document the **HTTP/SSE transport** path for MCP clients (currently "TBD" in README), including the M0 auth requirement. - [ ] Consider a **PyPI package** (`pipx install equationscp`) as an alternative to raw binaries. +- [ ] **Re-release after M0.** Every published v0.1.0 binary carries the RCE. Once M0 lands, cut a release and note the fix in the changelog. -**Acceptance:** a first-time user on macOS / Linux / Windows installs and runs in one documented step; `task build-release` works end-to-end; rc tags produce prereleases and final tags produce real releases; releases are gated on green tests. +**Acceptance:** a first-time user on macOS / Linux / Windows installs and runs in one documented step; `task build-release` works end-to-end; rc tags produce prereleases and final tags produce real releases; releases are gated on green tests *by an actual workflow dependency*. --- @@ -140,11 +175,17 @@ Project-level success: - [ ] Maintain `docs/` as the canonical documentation: a page per tool (`docs/.md`), kept current with the code. The `docs/README.md` index already lists tools — keep it updated. - [ ] Keep `docs/` and the top-level `README.md` in lockstep (examples, tool schemas, roadmap checkboxes). -- [ ] Reconcile the `src/server.py` vs root-`server.py` path references in `README.md` (README uses `src/server.py`; files are at the repo root). -- [ ] **Fix stale example outputs in `README.md` and `docs/`:** the shown steps/values contradict current code — e.g. README prints `"Subtract 5 from both sides: -3*y = 3"` and `y = -1.0`, but the code produces an `"Add …"` label and `y = -1`. Regenerate all example outputs from the fixed solver (do this *after* M0 so the examples reflect corrected behavior). +- [ ] Reconcile the `src/server.py` vs root-`server.py` path references in `README.md` (README uses `src/server.py` in four places; files are at the repo root). +- [ ] **Fix stale example outputs in `README.md` and `docs/linear-equations.md`.** *(Corrected in review round 2 — the earlier description of this defect was itself wrong.)* Verified against the code, the published examples differ in three ways, not one: + 1. **Operation labels are wrong.** README/docs show `"Subtract 3 from both sides"` and `"Subtract 5 from both sides"`; the code emits `"Add 4 …"` and `"Add 3 …"`. + 2. **The symbolic-form line is wrong.** Both show `"Equation in symbolic form: 2*x + 3 = 7"`; the code emits SymPy's `repr`: `"Eq(2*x + 3, 7)"`. + 3. **The divide step is wrong.** Both show `"Divide both sides by 2: x = 2.0"`; the code emits `x = 2` (exact) there while the *final* line and `solution.value` are `2.0`. (An earlier draft claimed the final value differed — it does not.) + Regenerate all example outputs from the fixed solver, *after* M0. +- [ ] **Correct the non-linear claim** in `docs/linear-equations.md` ("A non-linear equation … fails") and in `CLAUDE.md` ("Non-linear input (coefficient of the variable is 0) raises `ValueError`") — both are false for polynomials with a linear term. See M0. +- [ ] `docs/linear-equations.md` "Current limitations" repeats the `1/3` precision claim (correct) — refresh the whole section once M0 lands rather than leaving resolved items listed. - [ ] Add a top-level `CHANGELOG.md`; keep `Versionfile` and changelog aligned per release. -**Acceptance:** every tool has a current `docs/` page; `docs/`, `README.md`, and the code agree; path references are consistent; no stale example outputs remain. +**Acceptance:** every tool has a current `docs/` page; `docs/`, `README.md`, `CLAUDE.md`, and the code agree; path references are consistent; no stale example outputs remain; example outputs are generated from a test fixture so they cannot drift again. --- @@ -159,12 +200,15 @@ M6 (distribution) can proceed in parallel after M0. M7 (docs) trails each feature milestone (do the doc page as part of that milestone's "done"). ``` -**Hard rule:** M0 gates everything. No new solver ships without tests and back-substitution verification. +Within M0, the security work has its own internal order: **normalize → AST-guard → parse**. The LLM-syntax task and the AST-allowlist task are not independent checkboxes; landing the allowlist first will reject `x^2` and `2x`. + +**Hard rule:** M0 gates everything. No new solver ships without tests, a degree/shape check, and back-substitution verification. ## 5. Definition of done (every milestone) 1. Tests written and green in CI. -2. Solutions verified by substitution, not just string assembly. -3. Doc page added/updated in `docs/`. -4. `README.md` roadmap checkbox updated. -5. Committed in the `equationscp/` repo (its own remote — not the site's). +2. Solutions verified by substitution **and** checked for completeness (right shape, all roots). +3. All input paths run through the shared guard — no solver calls `parse_expr` directly. +4. Doc page added/updated in `docs/`. +5. `README.md` roadmap checkbox updated. +6. Committed in the `equationscp/` repo (its own remote — not the site's). diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..654b7eb --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +pythonpath = . +asyncio_mode = auto diff --git a/sandbox.py b/sandbox.py new file mode 100644 index 0000000..1f79cef --- /dev/null +++ b/sandbox.py @@ -0,0 +1,213 @@ +""" +Wall-clock bound on solving. + +The AST allowlist in `guard.py` rejects the syntax that reaches outside +arithmetic, but plenty of *legitimate* arithmetic is expensive, and SymPy has +no internal time budget. Something has to be able to stop a solve that is still +running. + +The tradeoff, documented because it constrains everything here: **a thread +cannot be cancelled in Python.** The previous `run_in_executor` approach could +time out the *caller* but never the work, so one pathological input wedged a +pool thread for the life of the process. Only a separate process can actually +be stopped, so solving happens in one. + +Spawning per request would be correct and unusably slow — importing SymPy costs +seconds. So a single worker process is started lazily and reused; when a solve +overruns, that process is killed and the next request transparently starts a +fresh one. Cost: solves are serialized behind a lock, which suits the stdio +transport (one client, one request at a time) and bounds concurrency on SSE. + +`spawn` is used rather than `fork`: `fork` is unavailable on Windows (M6 wants +Windows binaries) and unsafe alongside threads, which the async server has. + +If the worker cannot be started at all — a frozen binary in an environment +where `multiprocessing` misbehaves is the realistic case — solving falls back +to running in-process. That trades the hard timeout for staying functional, and +says so in the logs rather than failing every request. +""" + +from __future__ import annotations + +import logging +import multiprocessing +import os +import threading +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + +#: Wall-clock budget for a single solve. Generous for real equations, short +#: enough that a wedged solve doesn't hold the tool open. +DEFAULT_TIMEOUT_SECONDS = float(os.environ.get("EQUATIONSCP_SOLVE_TIMEOUT", "10")) + +#: Escape hatch for environments where the worker process can't run. Also what +#: the test suite uses to exercise the in-process path directly. +DISABLE_SUBPROCESS = os.environ.get("EQUATIONSCP_DISABLE_SUBPROCESS") == "1" + +#: How long a cold worker may take to come up. Importing SymPy under `spawn` +#: costs seconds, and that must not be charged against a request's solve +#: budget — otherwise the first equation after startup times out on a slow or +#: loaded machine even though nothing is wrong with it. +WORKER_STARTUP_TIMEOUT_SECONDS = 60.0 + + +class SolveTimeout(Exception): + """The solve exceeded its wall-clock budget and was terminated.""" + + +def _worker_loop(connection) -> None: + """Serve solve requests until the parent closes the pipe. + + Runs in the child process. Imports are deliberately inside the function: + under `spawn` this module is re-imported by the child, and paying for SymPy + only in the worker keeps the parent's import cheap. + """ + from solver import SolverError, _solve + + # Announce readiness only once SymPy is imported, so the parent can start + # the solve clock after the expensive part of startup rather than during it. + connection.send(("ready",)) + + while True: + try: + message = connection.recv() + except (EOFError, KeyboardInterrupt): + return + if message is None: + return + + equation_str, variable_str = message + try: + connection.send(("ok", _solve(equation_str, variable_str))) + except SolverError as exc: + connection.send(("solver_error", exc.code, exc.message, exc.steps)) + except BaseException as exc: # noqa: BLE001 — must not kill the worker + connection.send(("crash", f"{type(exc).__name__}: {exc}")) + + +class SolverProcess: + """A reusable worker process that can be killed when a solve overruns.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._process: Optional[multiprocessing.process.BaseProcess] = None + self._connection = None + + def _start(self) -> None: + context = multiprocessing.get_context("spawn") + parent_connection, child_connection = context.Pipe() + process = context.Process( + target=_worker_loop, args=(child_connection,), daemon=True + ) + process.start() + # The parent must drop its handle on the child end, or the pipe never + # reports EOF when the child dies. + child_connection.close() + self._process = process + self._connection = parent_connection + + # Block until the worker has imported SymPy. This is the whole point of + # the handshake: startup cost is paid here, outside any request's + # timeout budget. + if not parent_connection.poll(WORKER_STARTUP_TIMEOUT_SECONDS): + self._stop() + raise RuntimeError("the solver worker did not start in time") + if parent_connection.recv() != ("ready",): + self._stop() + raise RuntimeError("the solver worker sent an unexpected greeting") + + def _stop(self) -> None: + if self._process is not None: + self._process.kill() + self._process.join(timeout=5) + if self._connection is not None: + self._connection.close() + self._process = None + self._connection = None + + def _ensure_running(self) -> None: + if self._process is None or not self._process.is_alive(): + if self._process is not None: + self._stop() + self._start() + + def solve( + self, equation_str: str, variable_str: str, timeout: float + ) -> Dict[str, Any]: + """Run one solve under a hard time limit. + + Raises `SolveTimeout` if the budget is exceeded, and `SolverError` for + typed solver failures raised in the worker. + """ + from solver import SolverError + + with self._lock: + self._ensure_running() + assert self._connection is not None + + try: + self._connection.send((equation_str, variable_str)) + ready = self._connection.poll(timeout) + except (BrokenPipeError, OSError): + # Worker died between the liveness check and the send. + self._stop() + raise SolveTimeout("the solver worker stopped unexpectedly") + + if not ready: + # The work is still running and cannot be interrupted any other + # way. Kill it; the next request starts a fresh worker. + logger.warning( + "Solve exceeded %.1fs; terminating the worker process", timeout + ) + self._stop() + raise SolveTimeout( + f"the solve exceeded its {timeout:.0f}s time limit" + ) + + try: + response = self._connection.recv() + except EOFError: + self._stop() + raise SolveTimeout("the solver worker stopped unexpectedly") + + kind = response[0] + if kind == "ok": + return response[1] + if kind == "solver_error": + _, code, message, steps = response + raise SolverError(code, message, steps) + raise RuntimeError(response[1]) + + def shutdown(self) -> None: + with self._lock: + self._stop() + + +_PROCESS = SolverProcess() + + +def solve_with_timeout( + equation_str: str, + variable_str: str, + timeout: float = DEFAULT_TIMEOUT_SECONDS, +) -> Dict[str, Any]: + """Solve under a hard wall-clock limit, falling back to in-process on setup + failure (see the module docstring for why that tradeoff is deliberate).""" + from solver import SolverError, _solve + + if DISABLE_SUBPROCESS: + return _solve(equation_str, variable_str) + + try: + return _PROCESS.solve(equation_str, variable_str, timeout) + except (SolveTimeout, SolverError): + raise + except Exception as exc: # noqa: BLE001 — worker setup can fail many ways + logger.warning( + "Solver worker unavailable (%s: %s); falling back to an in-process " + "solve with no hard timeout", + type(exc).__name__, + exc, + ) + return _solve(equation_str, variable_str) diff --git a/schemas.py b/schemas.py index d506f4c..e1f24de 100644 --- a/schemas.py +++ b/schemas.py @@ -1,44 +1,110 @@ """ Schemas for the SymPy MCP Server. -This module defines the Pydantic models for input and output of the equation solver. +The output is a discriminated union tagged by `outcome`. "No solution", +"infinitely many solutions" and "this isn't linear" are ordinary results of +solving an equation, not failures, and an LLM consuming this tool needs to tell +them apart from each other and from a genuine error. Encoding them as one +tagged shape makes that unambiguous and keeps every future solver on the same +envelope. """ +from __future__ import annotations + +from typing import List, Literal, Optional, Union + from pydantic import BaseModel, Field -from typing import List +from typing_extensions import Annotated + +#: Raw equation strings are bounded here as well as in `guard.py`; validation +#: at the edge means an oversized payload never reaches the parser. +MAX_EQUATION_LENGTH = 500 class EquationInput(BaseModel): - """ - Input schema for the equation solver. - - Attributes: - equation (str): The equation to solve, e.g., "2*x + 3 = 7" - variable (str): The variable to solve for, e.g., "x" - """ - equation: str = Field(..., description="The equation to solve, e.g., '2*x + 3 = 7'") - variable: str = Field(..., description="The variable to solve for, e.g., 'x'") + """Input schema for the equation solver.""" + + equation: str = Field( + ..., + min_length=1, + max_length=MAX_EQUATION_LENGTH, + description="The equation to solve, e.g. '2*x + 3 = 7'", + ) + variable: str = Field( + ..., + min_length=1, + max_length=32, + # A variable name is an identifier, never an expression. Anything else + # is a caller error, and rejecting it here keeps the parser namespace + # under this module's control. + pattern=r"^[A-Za-z][A-Za-z0-9_]*$", + description="The variable to solve for, e.g. 'x'", + ) class Solution(BaseModel): + """A solved value. + + `value` is the exact SymPy form rendered as a string (`"1/3"`, + `"sqrt(2)/2"`, `"-I"`). Coercing to float would corrupt rationals and + raises outright on complex results, so the float is offered *alongside* the + exact form and is null whenever no real float exists. """ - Schema for the solution of an equation. - - Attributes: - variable (str): The variable that was solved for - value (float): The value of the variable - """ + variable: str = Field(..., description="The variable that was solved for") - value: float = Field(..., description="The value of the variable") + value: str = Field(..., description="The exact solution, e.g. '1/3'") + float_value: Optional[float] = Field( + None, + description="Decimal approximation, or null if the value is not a real number", + ) -class EquationOutput(BaseModel): - """ - Output schema for the equation solver. - - Attributes: - solution (Solution): The solution to the equation - steps (List[str]): Step-by-step explanation of how the equation was solved - """ - solution: Solution = Field(..., description="The solution to the equation") - steps: List[str] = Field(..., description="Step-by-step explanation of how the equation was solved") +class SolvedOutput(BaseModel): + """The equation has exactly one solution, verified by substitution.""" + + outcome: Literal["solved"] = "solved" + solution: Solution + steps: List[str] = Field(..., description="Step-by-step derivation") + + +class NoSolutionOutput(BaseModel): + """The equation is inconsistent — no value of the variable satisfies it.""" + + outcome: Literal["no_solution"] = "no_solution" + steps: List[str] = Field(..., description="Why no solution exists") + + +class InfiniteSolutionsOutput(BaseModel): + """The equation is an identity — every value of the variable satisfies it.""" + + outcome: Literal["infinite_solutions"] = "infinite_solutions" + steps: List[str] = Field(..., description="Why every value is a solution") + + +#: Machine-readable reason an equation could not be solved. Callers should +#: branch on this rather than on `message`, which is human-facing prose. +ErrorCode = Literal[ + "parse_error", # not a well-formed equation + "unsupported_input", # well-formed but outside what this tool accepts + "not_linear", # a valid equation, just not of degree 1 + "timeout", # the solve exceeded its wall-clock budget + "internal_error", # a bug on our side +] + + +class ErrorOutput(BaseModel): + """The equation could not be solved. Never carries internal detail.""" + + outcome: Literal["error"] = "error" + error: ErrorCode = Field(..., description="Machine-readable failure reason") + message: str = Field(..., description="Human-readable explanation") + steps: List[str] = Field( + default_factory=list, + description="Any steps completed before the failure", + ) + + +EquationOutput = Annotated[ + Union[SolvedOutput, NoSolutionOutput, InfiniteSolutionsOutput, ErrorOutput], + Field(discriminator="outcome"), +] diff --git a/server.py b/server.py index 01b8b42..23d7243 100644 --- a/server.py +++ b/server.py @@ -1,79 +1,105 @@ """ FastMCP server for solving algebraic equations using SymPy. -This module sets up a FastMCP server that exposes a tool for solving -linear equations with step-by-step solutions. +Exposes `solve_linear_equation`. Every failure mode is reported as a typed +outcome in the tool's return value rather than as an exception, so a client can +branch on `outcome` instead of pattern-matching prose. """ -import logging -from fastmcp import FastMCP -import asyncio +from __future__ import annotations -from schemas import EquationInput, EquationOutput -from solver import sympy_solve_linear_equation, format_solution +import asyncio +import logging +import multiprocessing +from fastmcp import FastMCP -# Configure logging -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +from sandbox import SolveTimeout, solve_with_timeout +from schemas import ( + EquationInput, + EquationOutput, + ErrorOutput, + InfiniteSolutionsOutput, + NoSolutionOutput, + SolvedOutput, ) -logger = logging.getLogger(__name__) +from solver import SolverError + +# Scoped to this package's logger, at INFO. Calling `basicConfig` at import time +# would set the *root* logger and every dependency would emit DEBUG for the life +# of the process — on the stdio transport that is a lot of noise on stderr for +# no benefit. Equation text is logged at DEBUG only: it is user content, and +# routine operation shouldn't record it. +logger = logging.getLogger("equationscp") +logger.setLevel(logging.INFO) +logger.addHandler(logging.NullHandler()) -# Create the FastMCP server server = FastMCP( name="sympy-solver", title="SymPy Equation Solver", description="A FastMCP server for solving algebraic equations using SymPy", - version="0.1.0" + version="0.1.0", ) +_OUTCOME_MODELS = { + "solved": SolvedOutput, + "no_solution": NoSolutionOutput, + "infinite_solutions": InfiniteSolutionsOutput, +} + + @server.tool( name="solve_linear_equation", - description="Solve a linear equation and provide step-by-step solutions", + description=( + "Solve a linear equation in one variable and return the exact solution " + "with a step-by-step derivation. Reports 'no_solution', " + "'infinite_solutions' and 'error' as distinct outcomes." + ), ) async def solve_linear_equation(input_data: EquationInput) -> EquationOutput: - """ - Solve a linear equation and provide a step-by-step solution. - - Args: - input_data (EquationInput): The equation and variable to solve for - - Returns: - EquationOutput: The solution and step-by-step explanation - """ - logger.info(f"Solving equation: {input_data.equation} for {input_data.variable}") - + """Solve a linear equation and provide a step-by-step solution.""" + logger.info("Solving for variable %s", input_data.variable) + logger.debug("Equation: %s", input_data.equation) + try: - # Solve the equation - loop = asyncio.get_event_loop() - solution_value, steps = await loop.run_in_executor( - None, - lambda: sympy_solve_linear_equation(input_data.equation, input_data.variable) + result = await asyncio.to_thread( + solve_with_timeout, input_data.equation, input_data.variable ) - ''' - solution_value, steps = sympy_solve_linear_equation( - input_data.equation, - input_data.variable + except SolverError as exc: + logger.info("Solve refused (%s): %s", exc.code, exc.message) + return ErrorOutput(error=exc.code, message=exc.message, steps=exc.steps) + except SolveTimeout as exc: + logger.warning("Solve timed out: %s", exc) + return ErrorOutput( + error="timeout", + message=( + "Solving this equation took too long and was stopped. " + "Try a simpler expression." + ), ) - ''' - # Format the solution - result = format_solution( - solution_value, - input_data.variable, - steps + except Exception: + # Detail stays in the log. Raw exception text has leaked Python + # SyntaxError fragments to clients before; a caller gets a stable, + # typed error and nothing about our internals. + logger.exception("Unexpected failure while solving") + return ErrorOutput( + error="internal_error", + message="An unexpected error occurred while solving the equation.", ) - - return EquationOutput(**result) - - except Exception as e: - logger.error(f"Error solving equation: {str(e)}") - raise ValueError(f"Failed to solve equation: {str(e)}") + return _OUTCOME_MODELS[result["outcome"]](**result) if __name__ == "__main__": + # Required before any process is spawned from a PyInstaller binary; + # a no-op otherwise. + multiprocessing.freeze_support() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) logger.info("Starting SymPy Equation Solver MCP server") server.run() diff --git a/solver.py b/solver.py index f006897..fbd7fb5 100644 --- a/solver.py +++ b/solver.py @@ -1,100 +1,242 @@ """ -Equation solver using SymPy. +Linear equation solver. -This module provides functionality to solve linear equations and generate -step-by-step solutions. +Two properties this module is built around: + +**Soundness.** Every returned value is substituted back into the *original* +equation and proven to satisfy it before it leaves this module. Nothing is +returned on the strength of string assembly. + +**Completeness.** Substitution proves a root is *a* solution; it cannot prove +it is the *only* one. `x**2 + x = 4` has two roots, and either one +back-substitutes perfectly — so a solver that checks only soundness will +happily return half the answer with a fabricated linear derivation. The degree +check in `_solve` runs *before* solving for exactly this reason: this tool +answers linear equations or refuses, and never silently answers a different +question than the one asked. + +The step narrative is generated from the operations actually performed, in the +order performed, so the label and the equation on each line cannot drift apart. """ +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + import sympy as sp -from sympy.parsing.sympy_parser import parse_expr -from typing import List, Tuple, Dict, Any + +from guard import EquationSyntaxError, UnsafeExpressionError, safe_parse + + +class SolverError(Exception): + """A solve failed in a way the caller should report as a typed outcome.""" + + def __init__(self, code: str, message: str, steps: Optional[List[str]] = None): + super().__init__(message) + self.code = code + self.message = message + self.steps = steps or [] + + +def _split_equation(equation_str: str) -> Tuple[str, str]: + """Split `"lhs = rhs"`, tolerating the `==` an LLM sometimes emits.""" + normalized = equation_str.replace("==", "=") + parts = normalized.split("=") + if len(parts) == 1: + raise SolverError("parse_error", "The equation must contain an equals sign (=).") + if len(parts) > 2: + raise SolverError( + "parse_error", + "The equation must contain exactly one equals sign (=).", + ) + return parts[0], parts[1] + + +def _render(lhs: sp.Expr, rhs: sp.Expr) -> str: + """Render an equation the way a human writes it, not as SymPy's `Eq` repr.""" + return f"{sp.sstr(lhs)} = {sp.sstr(rhs)}" -def sympy_solve_linear_equation(equation_str: str, variable_str: str) -> Tuple[float, List[str]]: +def _cancel_term( + term: sp.Expr, lhs: sp.Expr, rhs: sp.Expr +) -> Tuple[sp.Expr, sp.Expr, str]: + """Subtract `term` from both sides and describe what was done. + + The description is derived from the operation itself, so the label and the + equation printed beside it cannot disagree. + + Sign is read from SymPy's tri-state assumptions rather than a `>` + comparison: the sign of a symbolic term is simply unknown, and asking for it + with `>` raises `TypeError: cannot determine truth value of Relational`. + Unknown sign gets neutral phrasing instead of a crash. """ - Solve a linear equation and provide step-by-step solution. - - Args: - equation_str (str): The equation to solve, e.g., "2*x + 3 = 7" - variable_str (str): The variable to solve for, e.g., "x" - - Returns: - Tuple[float, List[str]]: A tuple containing the solution value and a list of steps - - Raises: - ValueError: If the equation is not linear or cannot be solved + new_lhs = sp.expand(lhs - term) + new_rhs = sp.expand(rhs - term) + + if term.is_negative: + label = f"Add {sp.sstr(-term)} to both sides" + else: + label = f"Subtract {sp.sstr(term)} from both sides" + + return new_lhs, new_rhs, f"{label}: {_render(new_lhs, new_rhs)}" + + +def _variable_part(expr: sp.Expr, variable: sp.Symbol) -> sp.Expr: + """The part of `expr` that involves `variable`.""" + return sp.expand(expr - expr.subs(variable, 0)) + + +def _constant_part(expr: sp.Expr, variable: sp.Symbol) -> sp.Expr: + """The part of `expr` that does not involve `variable`.""" + return sp.expand(expr.subs(variable, 0)) + + +def _degree_in(expr: sp.Expr, variable: sp.Symbol) -> int: + """Polynomial degree of `expr` in `variable`. + + Raises `SolverError` when `expr` is not a polynomial in the variable at all + (`sin(x) + x`, `1/x`), which is the other half of the completeness check. """ - # Parse the variable - variable = sp.Symbol(variable_str) - - # Parse the equation - steps = [f"Original equation: {equation_str}"] - - # Split the equation into left and right sides - if "=" not in equation_str: - raise ValueError("Equation must contain an equals sign (=)") - - left_str, right_str = equation_str.split("=", 1) - left_expr = parse_expr(left_str.strip()) - right_expr = parse_expr(right_str.strip()) - - # Move all terms with the variable to the left side - # and all other terms to the right side - equation = sp.Eq(left_expr, right_expr) - steps.append(f"Equation in symbolic form: {equation}") - - # Rearrange to standard form: ax + b = 0 - rearranged = sp.solve(equation, variable)[0] - - # Generate step-by-step solution - # For a linear equation ax + b = c, we do: - # 1. Subtract b from both sides: ax = c - b - # 2. Divide both sides by a: x = (c - b) / a - - # Get coefficients from the original equation - expanded = sp.expand(left_expr - right_expr) - coeffs = sp.collect(expanded, variable, evaluate=False) - - # Extract the coefficient of the variable and the constant term - a = coeffs.get(variable, 0) - b = coeffs.get(1, 0) - - if a == 0: - raise ValueError(f"Equation is not linear in {variable_str}") - - # Generate steps - if b != 0: - if b > 0: - steps.append(f"Subtract {b} from both sides: {a}*{variable_str} = -{b}") - else: - steps.append(f"Add {-b} to both sides: {a}*{variable_str} = {-b}") - - if a != 1: - steps.append(f"Divide both sides by {a}: {variable_str} = {rearranged}") - - # Final solution - solution_value = float(rearranged) - steps.append(f"Solution: {variable_str} = {solution_value}") - - return solution_value, steps - - -def format_solution(value: float, variable: str, steps: List[str]) -> Dict[str, Any]: + try: + return sp.Poly(expr, variable).degree() + except sp.PolynomialError as exc: + raise SolverError( + "not_linear", + f"This is not a polynomial equation in {variable}; " + "only linear equations are supported.", + ) from exc + + +def _exact_and_float(value: sp.Expr) -> Tuple[str, Optional[float]]: + """Exact string form plus a float approximation where one exists. + + Complex and symbolic results have no real float; they get `None` rather + than the `TypeError` that unconditional coercion produced. """ - Format the solution and steps into the expected output format. - - Args: - value (float): The solution value - variable (str): The variable that was solved for - steps (List[str]): The step-by-step solution - - Returns: - Dict[str, Any]: Formatted solution + try: + approximate: Optional[float] = float(value) + except (TypeError, ValueError): + approximate = None + return sp.sstr(value), approximate + + +def _verify( + lhs: sp.Expr, rhs: sp.Expr, variable: sp.Symbol, candidate: sp.Expr +) -> None: + """Prove `candidate` satisfies the original equation, or refuse to return it.""" + residual = sp.simplify((lhs - rhs).subs(variable, candidate)) + if residual.is_zero: + return + raise SolverError( + "internal_error", + "The computed solution could not be verified against the original equation.", + ) + + +def _solve(equation_str: str, variable_str: str) -> Dict[str, Any]: + """Solve a linear equation. Pure and synchronous — no timeout, no I/O. + + Returns a dict matching one of the `schemas.EquationOutput` members, and + raises `SolverError` for every typed failure. """ + variable = sp.Symbol(variable_str) + steps = [f"Original equation: {equation_str.strip()}"] + + left_str, right_str = _split_equation(equation_str) + + local_dict = {variable_str: variable} + try: + lhs = sp.expand(safe_parse(left_str, local_dict)) + rhs = sp.expand(safe_parse(right_str, local_dict)) + except UnsafeExpressionError as exc: + raise SolverError("unsupported_input", str(exc), steps) from exc + except EquationSyntaxError as exc: + raise SolverError("parse_error", str(exc), steps) from exc + + # Kept for verification: the rearrangement below must be checked against + # the equation as given, not against its own output. + original_lhs, original_rhs = lhs, rhs + + steps.append(f"Equation in symbolic form: {_render(lhs, rhs)}") + + difference = sp.expand(lhs - rhs) + + # Identity and contradiction are answers, not errors, and both are + # indistinguishable to `sp.solve`, which returns `[]` for each. + if difference.is_zero: + steps.append( + "Both sides are identical, so the equation holds for every value " + f"of {variable_str}." + ) + return {"outcome": "infinite_solutions", "steps": steps} + + # Degree *before* solving — see the module docstring. + degree = _degree_in(difference, variable) + if degree == 0: + if variable not in difference.free_symbols: + steps.append( + f"After cancelling, {variable_str} no longer appears, and the " + f"remaining statement {sp.sstr(difference)} = 0 is false." + ) + return {"outcome": "no_solution", "steps": steps} + if degree > 1: + raise SolverError( + "not_linear", + f"This equation is of degree {degree} in {variable_str}; " + "only linear equations are supported.", + steps, + ) + + # --- Isolate the variable, narrating each operation as it happens. --- + + right_variable_part = _variable_part(rhs, variable) + if not right_variable_part.is_zero: + lhs, rhs, step = _cancel_term(right_variable_part, lhs, rhs) + steps.append(step) + + left_constant_part = _constant_part(lhs, variable) + if not left_constant_part.is_zero: + lhs, rhs, step = _cancel_term(left_constant_part, lhs, rhs) + steps.append(step) + + coefficient = sp.simplify(lhs.coeff(variable, 1)) + if coefficient.is_zero: + # Unreachable given the degree check, but a wrong answer here would be + # silent, so assert rather than divide by zero. + raise SolverError( + "internal_error", + f"The coefficient of {variable_str} vanished while isolating it.", + steps, + ) + + if coefficient.free_symbols: + steps.append( + f"This assumes {sp.sstr(coefficient)} is non-zero; if it is zero, " + "the equation has either no solution or infinitely many." + ) + + solution = sp.simplify(rhs / coefficient) + if coefficient != 1: + steps.append( + f"Divide both sides by {sp.sstr(coefficient)}: " + f"{variable_str} = {sp.sstr(solution)}" + ) + + _verify(original_lhs, original_rhs, variable, solution) + + exact, approximate = _exact_and_float(solution) + final = f"Solution: {variable_str} = {exact}" + # Only worth showing a decimal when the exact form isn't already one. + if approximate is not None and not (solution.is_Integer or solution.is_Float): + final += f" (≈ {approximate})" + steps.append(final) + return { + "outcome": "solved", "solution": { - "variable": variable, - "value": value + "variable": variable_str, + "value": exact, + "float_value": approximate, }, - "steps": steps + "steps": steps, } diff --git a/tests/test_guard.py b/tests/test_guard.py new file mode 100644 index 0000000..b09e5d9 --- /dev/null +++ b/tests/test_guard.py @@ -0,0 +1,134 @@ +"""Tests for the untrusted-input guard. + +The first test here is the one that matters most: the payload it asserts +against executed successfully against the pre-M0 solver and returned the +server's PID in `solution.value`. +""" + +import ast + +import pytest +import sympy as sp + +from guard import ( + MAX_EXPONENT, + EquationSyntaxError, + UnsafeExpressionError, + assert_safe, + safe_parse, +) + +LOCAL_DICT = {"x": sp.Symbol("x")} + + +# Each of these reached the Python object graph through `parse_expr`. They must +# be *rejected*, not merely return something harmless. +CODE_EXECUTION_PAYLOADS = [ + 'sqrt.__globals__["__builtins__"]["__import__"]("os").getpid()', + 'sqrt.__globals__["__builtins__"]["__import__"]("os").system("true")', + "().__class__.__bases__[0].__subclasses__()", + "x.__class__", + "x.__class__.__mro__", + 'x.__reduce__()', +] + + +@pytest.mark.parametrize("payload", CODE_EXECUTION_PAYLOADS) +def test_code_execution_payloads_are_rejected(payload): + with pytest.raises((UnsafeExpressionError, EquationSyntaxError)): + safe_parse(payload, dict(LOCAL_DICT)) + + +def test_rce_payload_does_not_execute(tmp_path): + """Belt and braces: prove nothing ran, not just that an error was raised.""" + marker = tmp_path / "pwned" + payload = ( + 'sqrt.__globals__["__builtins__"]["__import__"]("pathlib")' + f'.Path("{marker}").touch()' + ) + with pytest.raises((UnsafeExpressionError, EquationSyntaxError)): + safe_parse(payload, dict(LOCAL_DICT)) + assert not marker.exists() + + +@pytest.mark.parametrize( + "expression,expected", + [ + ("2*x + 3", "2*x + 3"), + ("2x + 3", "2*x + 3"), # implicit multiplication + ("x^2", "x**2"), # caret means power, not XOR + ("sqrt(2)/2", "sqrt(2)/2"), + ("-3*x + 5", "5 - 3*x"), + ("1.5*x", "1.5*x"), + ], +) +def test_legitimate_math_still_parses(expression, expected): + assert sp.sstr(safe_parse(expression, dict(LOCAL_DICT))) == expected + + +def test_normalization_runs_before_the_allowlist(): + """`x^2` is a BitXor node and `2x` a SyntaxError until normalized. + + Guarding the raw string instead of the normalized code would reject both — + this asserts the ordering the pipeline depends on. + """ + with pytest.raises(UnsafeExpressionError): + assert_safe("x ^ 2") # raw form: BitXor, rejected + assert sp.sstr(safe_parse("x^2", dict(LOCAL_DICT))) == "x**2" # normalized: fine + + +def test_exponent_towers_are_rejected(): + # Pure arithmetic, passes any syntax-only filter, exhausts memory on eval. + with pytest.raises(UnsafeExpressionError, match="nested exponents"): + safe_parse("10**10**8", dict(LOCAL_DICT)) + + +def test_oversized_exponent_is_rejected(): + with pytest.raises(UnsafeExpressionError, match="exceeds the maximum"): + safe_parse(f"10**{MAX_EXPONENT + 1}", dict(LOCAL_DICT)) + + +def test_ordinary_exponent_is_allowed(): + assert sp.sstr(safe_parse("x**3", dict(LOCAL_DICT))) == "x**3" + + +def test_unknown_function_is_rejected(): + """Rejected, not silently reinterpreted. + + With `split_symbols` in the transformation set this parsed happily as + `9*a**2*c*f*i*l*o*r*t` — a different question, answered without comment. + """ + with pytest.raises((UnsafeExpressionError, EquationSyntaxError)): + safe_parse("factorial(200000)", dict(LOCAL_DICT)) + + +def test_multi_letter_variables_survive(): + """`split_symbols` would shred these; the variable is protected regardless, + but the transformation set must not mangle the rest of the expression.""" + local = {"rate": sp.Symbol("rate")} + assert sp.sstr(safe_parse("2*rate + 3", dict(local))) == "2*rate + 3" + assert sp.sstr(safe_parse("2rate", dict(local))) == "2*rate" + + +def test_complexity_limit(): + with pytest.raises(UnsafeExpressionError, match="too complex"): + assert_safe("+".join(["1"] * 300)) + + +def test_empty_expression(): + with pytest.raises(EquationSyntaxError): + safe_parse(" ", dict(LOCAL_DICT)) + + +def test_allowlist_is_a_whitelist(): + """Node types the allowlist doesn't name are rejected, not ignored.""" + for source in ["[1, 2]", "{1: 2}", "lambda: 1", "(i for i in [1])", "1 if x else 2"]: + with pytest.raises(UnsafeExpressionError): + assert_safe(source) + + +def test_allowlist_operates_on_what_sympy_evaluates(): + """The guard must see the transformed code, not the user's raw string.""" + tree = ast.parse("Integer (2 )*x +Integer (3 )", mode="eval") + assert isinstance(tree.body, ast.BinOp) # sanity: this is the real shape + assert_safe("Integer (2 )*x +Integer (3 )") diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 0000000..6fcf67c --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,97 @@ +"""Tests for the wall-clock bound. + +These drive the real worker process. A mocked worker would prove nothing: the +mechanism exists precisely because the work cannot be interrupted from inside +the interpreter running it, so the test has to make real work and really stop +it. +""" + +import time + +import pytest + +from sandbox import SolverProcess, SolveTimeout +from solver import SolverError + +#: Legitimate arithmetic — it passes the AST allowlist, which caps exponents +#: and bans towers — that still takes ~17s in-process. This is the residual +#: DoS surface the allowlist cannot address and the timeout exists for. +SLOW_BUT_ALLOWED = "x = (sqrt(2)+sqrt(3)+sqrt(5)+sqrt(7)+sqrt(11))**40" + + +@pytest.fixture +def worker(): + process = SolverProcess() + # Pay startup once, up front, so the assertions below measure solving + # rather than SymPy's import time. + process.solve("x = 1", "x", timeout=60) + yield process + process.shutdown() + + +def test_solves_through_the_worker_process(worker): + result = worker.solve("2*x + 3 = 7", "x", timeout=60) + assert result["outcome"] == "solved" + assert result["solution"]["value"] == "2" + + +def test_worker_is_reused_across_solves(worker): + """Spawning per request would be correct but pays SymPy's import each time.""" + worker.solve("2*x + 3 = 7", "x", timeout=60) + first = worker._process.pid + worker.solve("3*x = 1", "x", timeout=60) + assert worker._process.pid == first + + +def test_startup_is_not_charged_against_the_solve_budget(): + """A cold worker must not make the first request time out. + + The budget here is far shorter than SymPy's import; only the handshake in + `_start` keeps this from failing. + """ + process = SolverProcess() + try: + result = process.solve("2*x + 3 = 7", "x", timeout=2) + assert result["outcome"] == "solved" + finally: + process.shutdown() + + +def test_typed_solver_errors_cross_the_process_boundary(worker): + with pytest.raises(SolverError) as excinfo: + worker.solve("x**2 + x = 4", "x", timeout=60) + assert excinfo.value.code == "not_linear" + assert excinfo.value.steps # steps completed before the refusal survive + + +def test_timeout_stops_work_that_cannot_be_interrupted(worker): + """The real property: the call returns long before the solve would have.""" + started = time.monotonic() + with pytest.raises(SolveTimeout): + worker.solve(SLOW_BUT_ALLOWED, "x", timeout=2) + elapsed = time.monotonic() - started + + # In-process this input takes ~17s. Returning in a couple of seconds is + # only possible because the process was killed mid-solve. + assert elapsed < 10, f"returned after {elapsed:.1f}s — work was not stopped" + + +def test_timeout_actually_kills_the_process(worker): + """Without this, the 'timeout' would just abandon a still-running solve.""" + before = worker._process.pid + with pytest.raises(SolveTimeout): + worker.solve(SLOW_BUT_ALLOWED, "x", timeout=2) + assert worker._process is None or not worker._process.is_alive() + + worker.solve("x = 1", "x", timeout=60) + assert worker._process.pid != before + + +def test_worker_recovers_after_a_timeout(worker): + with pytest.raises(SolveTimeout): + worker.solve(SLOW_BUT_ALLOWED, "x", timeout=2) + + # A killed worker must not poison subsequent requests. + result = worker.solve("2*x + 3 = 7", "x", timeout=60) + assert result["outcome"] == "solved" + assert result["solution"]["value"] == "2" diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..f9a1523 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,89 @@ +"""End-to-end tests driving the FastMCP tool in-process. + +These assert the `EquationOutput` envelope a real MCP client receives, and that +no input produces an exception rather than a typed outcome. +""" + +import pytest +from fastmcp import Client + +import server +from schemas import EquationInput, ErrorOutput, SolvedOutput + + +async def call(equation, variable="x"): + """Invoke the tool function the way the server does.""" + return await server.solve_linear_equation( + EquationInput(equation=equation, variable=variable) + ) + + +async def test_solved_envelope(): + result = await call("2*x + 3 = 7") + assert isinstance(result, SolvedOutput) + assert result.outcome == "solved" + assert result.solution.variable == "x" + assert result.solution.value == "2" + assert result.solution.float_value == 2.0 + assert result.steps[0] == "Original equation: 2*x + 3 = 7" + + +async def test_no_solution_envelope(): + result = await call("x = x + 1") + assert result.outcome == "no_solution" + + +async def test_infinite_solutions_envelope(): + result = await call("x = x") + assert result.outcome == "infinite_solutions" + + +async def test_not_linear_envelope(): + result = await call("x**2 + x = 4") + assert isinstance(result, ErrorOutput) + assert result.error == "not_linear" + + +async def test_hostile_input_is_a_typed_error_not_an_exception(): + result = await call('x = sqrt.__globals__["__builtins__"]["__import__"]("os").getpid()') + assert isinstance(result, ErrorOutput) + assert result.error in {"unsupported_input", "parse_error"} + + +async def test_errors_carry_no_internal_detail(): + result = await call("2*x + = 7") + assert isinstance(result, ErrorOutput) + for leak in ["Traceback", "", "line 1", "sympy", "File \""]: + assert leak not in result.message + + +@pytest.mark.parametrize( + "equation,variable", + [ + ("", "x"), # empty equation + ("x = 1" * 200, "x"), # over the length cap + ("x = 1", ""), # empty variable + ("x = 1", "2x"), # not an identifier + ("x = 1", "__import__"), # dunder as a variable name + ], +) +def test_input_validation_rejects_bad_arguments(equation, variable): + from pydantic import ValidationError + + with pytest.raises(ValidationError): + EquationInput(equation=equation, variable=variable) + + +async def test_tool_is_registered_and_callable_over_mcp(): + """Drive it through a real MCP client session, not just the Python function.""" + async with Client(server.server) as client: + tools = await client.list_tools() + assert any(tool.name == "solve_linear_equation" for tool in tools) + + response = await client.call_tool( + "solve_linear_equation", + {"input_data": {"equation": "2*x + 3 = 7", "variable": "x"}}, + ) + payload = response[0].text + assert "solved" in payload + assert "Subtract 3 from both sides" in payload diff --git a/tests/test_solver.py b/tests/test_solver.py new file mode 100644 index 0000000..d1f28d0 --- /dev/null +++ b/tests/test_solver.py @@ -0,0 +1,260 @@ +"""Tests for the linear solver. + +Happy-path tests pin the *entire* step list, not just the value. The derivation +is the product; a suite that checks only `value` passes even when every +operation label is wrong, which is exactly how the pre-M0 mislabelling +survived. +""" + +import pytest + +from solver import SolverError, _solve + + +def solve(equation, variable="x"): + return _solve(equation, variable) + + +# -------------------------------------------------------------------------- +# Happy paths — full step lists +# -------------------------------------------------------------------------- + +def test_basic_equation_steps(): + result = solve("2*x + 3 = 7") + assert result["outcome"] == "solved" + assert result["solution"] == {"variable": "x", "value": "2", "float_value": 2.0} + assert result["steps"] == [ + "Original equation: 2*x + 3 = 7", + "Equation in symbolic form: 2*x + 3 = 7", + "Subtract 3 from both sides: 2*x = 4", + "Divide both sides by 2: x = 2", + "Solution: x = 2", + ] + + +def test_negative_coefficient_steps(): + result = solve("-3*y + 5 = 8", "y") + assert result["solution"] == {"variable": "y", "value": "-1", "float_value": -1.0} + assert result["steps"] == [ + "Original equation: -3*y + 5 = 8", + "Equation in symbolic form: 5 - 3*y = 8", + "Subtract 5 from both sides: -3*y = 3", + "Divide both sides by -3: y = -1", + "Solution: y = -1", + ] + + +def test_variable_on_both_sides_steps(): + result = solve("2*x + 1 = x + 4") + assert result["solution"]["value"] == "3" + assert result["steps"] == [ + "Original equation: 2*x + 1 = x + 4", + "Equation in symbolic form: 2*x + 1 = x + 4", + "Subtract x from both sides: x + 1 = 4", + "Subtract 1 from both sides: x = 3", + "Solution: x = 3", + ] + + +def test_step_labels_match_the_operation(): + """The pre-M0 solver printed 'Add 4 to both sides' for this equation.""" + steps = solve("2*x + 3 = 7")["steps"] + assert "Subtract 3 from both sides: 2*x = 4" in steps + assert not any(step.startswith("Add 4") for step in steps) + + +def test_symbolic_form_is_not_a_sympy_repr(): + """It used to render `Eq(2*x + 3, 7)`, which no doc example ever showed.""" + steps = solve("2*x + 3 = 7")["steps"] + assert "Equation in symbolic form: 2*x + 3 = 7" in steps + + +def test_addition_when_the_constant_is_negative(): + result = solve("2*x - 6 = 0") + assert "Add 6 to both sides: 2*x = 6" in result["steps"] + assert result["solution"]["value"] == "3" + + +# -------------------------------------------------------------------------- +# Exact values — no coercion +# -------------------------------------------------------------------------- + +def test_rational_result_stays_exact(): + result = solve("3*x = 1") + assert result["solution"]["value"] == "1/3" + assert result["solution"]["float_value"] == pytest.approx(1 / 3) + assert "Solution: x = 1/3 (≈ 0.3333333333333333)" in result["steps"] + + +def test_irrational_result_stays_exact(): + result = solve("2*x = sqrt(2)") + assert result["solution"]["value"] == "sqrt(2)/2" + assert result["solution"]["float_value"] == pytest.approx(0.7071067811865476) + + +def test_complex_result_does_not_crash(): + """`float()` on a complex root raised TypeError before M0.""" + result = solve("I*x = 1") + assert result["outcome"] == "solved" + assert result["solution"]["value"] == "-I" + assert result["solution"]["float_value"] is None + + +def test_integer_result_omits_the_redundant_approximation(): + assert "Solution: x = 2" in solve("2*x + 3 = 7")["steps"] + assert not any("≈" in step for step in solve("2*x + 3 = 7")["steps"]) + + +# -------------------------------------------------------------------------- +# Completeness — the silent-wrong-answer class +# -------------------------------------------------------------------------- + +def test_quadratic_with_a_linear_term_is_refused(): + """Pre-M0 this returned 1.5615..., one of two roots, with linear steps. + + Back-substitution cannot catch it: that root satisfies the equation. Only + the degree check does. + """ + with pytest.raises(SolverError) as excinfo: + solve("x**2 + x = 4") + assert excinfo.value.code == "not_linear" + assert "degree 2" in excinfo.value.message + + +def test_cubic_with_a_linear_term_is_refused(): + """Pre-M0 this returned 0.0, silently dropping ±sqrt(2).""" + with pytest.raises(SolverError) as excinfo: + solve("x**3 - 2*x = 0") + assert excinfo.value.code == "not_linear" + assert "degree 3" in excinfo.value.message + + +def test_quadratic_without_a_linear_term_is_refused(): + with pytest.raises(SolverError) as excinfo: + solve("x**2 = 4") + assert excinfo.value.code == "not_linear" + + +def test_non_polynomial_is_refused(): + for equation in ["sin(x) + x = 0", "1/x = 2"]: + with pytest.raises(SolverError) as excinfo: + solve(equation) + assert excinfo.value.code == "not_linear" + + +def test_higher_degree_that_cancels_to_linear_is_solved(): + """`x**2` on both sides cancels; the equation really is linear.""" + result = solve("x**2 + x = x**2 + 4") + assert result["outcome"] == "solved" + assert result["solution"]["value"] == "4" + + +# -------------------------------------------------------------------------- +# Typed outcomes +# -------------------------------------------------------------------------- + +def test_no_solution(): + result = solve("x = x + 1") + assert result["outcome"] == "no_solution" + assert result["steps"][0] == "Original equation: x = x + 1" + + +def test_infinite_solutions(): + result = solve("x = x") + assert result["outcome"] == "infinite_solutions" + + +def test_unknown_variable_is_no_solution_not_an_index_error(): + """This raised `IndexError: list index out of range` before M0.""" + result = solve("2*x + 3 = 7", "z") + assert result["outcome"] == "no_solution" + + +@pytest.mark.parametrize( + "equation,code", + [ + ("2*x + 3", "parse_error"), # no equals sign + ("2*x = 3 = 4", "parse_error"), # more than one equals sign + ("= 5", "parse_error"), # empty side + ("2*x + = 7", "parse_error"), + ], +) +def test_malformed_equations(equation, code): + with pytest.raises(SolverError) as excinfo: + solve(equation) + assert excinfo.value.code == code + + +def test_errors_do_not_leak_python_exception_text(): + for equation in ["2*x + = 7", "x**2 = 4", "sqrt(2)^^2 = x"]: + with pytest.raises(SolverError) as excinfo: + solve(equation) + message = excinfo.value.message + assert "Traceback" not in message + assert "line 1" not in message + assert "" not in message + + +# -------------------------------------------------------------------------- +# LLM-idiomatic syntax +# -------------------------------------------------------------------------- + +def test_caret_is_exponentiation_not_xor(): + """`x^2 + x = 4` used to raise `TypeError: unsupported operand type ^`.""" + with pytest.raises(SolverError) as excinfo: + solve("x^2 + x = 4") + assert excinfo.value.code == "not_linear" # parsed as a power, then refused + + +def test_implicit_multiplication(): + result = solve("2x + 3 = 7") + assert result["solution"]["value"] == "2" + + +def test_double_equals_is_accepted(): + result = solve("2*x + 3 == 7") + assert result["solution"]["value"] == "2" + + +def test_whitespace_is_tolerated(): + result = solve(" 2*x + 3 = 7 ") + assert result["solution"]["value"] == "2" + + +# -------------------------------------------------------------------------- +# Parametric input — M0 requires it not to crash; M4 makes it first-class +# -------------------------------------------------------------------------- + +def test_parametric_equation_does_not_raise_typeerror(): + """`if b > 0` raised `TypeError: cannot determine truth value of Relational`.""" + result = solve("a*x + b = c") + assert result["outcome"] == "solved" + assert result["solution"]["value"] == "(-b + c)/a" + assert result["solution"]["float_value"] is None + + +def test_parametric_equation_states_its_assumption(): + steps = solve("a*x + b = c")["steps"] + assert any("non-zero" in step for step in steps) + + +def test_symbolic_constant_uses_neutral_phrasing(): + """Unknown sign must not crash and must not assert a direction it can't know.""" + steps = solve("x + b = 0")["steps"] + assert any(step.startswith("Subtract b from both sides") for step in steps) + + +# -------------------------------------------------------------------------- +# Hostile input reaches the solver's typed errors, not an exception +# -------------------------------------------------------------------------- + +def test_code_execution_payload_is_a_typed_refusal(): + with pytest.raises(SolverError) as excinfo: + solve('x = sqrt.__globals__["__builtins__"]["__import__"]("os").getpid()') + assert excinfo.value.code in {"unsupported_input", "parse_error"} + + +def test_exponent_tower_is_a_typed_refusal(): + with pytest.raises(SolverError) as excinfo: + solve("10**10**8 + x = 0") + assert excinfo.value.code == "unsupported_input"