From 086a84e02e596bc7301fab5582c2664490dfb39d Mon Sep 17 00:00:00 2001 From: Rudi MK Date: Mon, 3 Aug 2026 06:56:00 +0000 Subject: [PATCH 1/9] plans: second momus review pass over the Sisyphus project plan Re-ran every factual claim in PROJECT_PLAN.md against the code and revised the plan accordingly. Critical: - M0's input-security item understated the defect. parse_expr eval()s transformed input and attribute access passes through, so an equation string reaches the Python object graph and executes arbitrary code. With http-server bound to 0.0.0.0:4242 unauthenticated, that is pre-auth RCE. The remediation the plan prescribed (size limit + restricted local_dict) was verified not to block it; an AST-level allowlist was verified to block it while leaving normal math intact. - The security tasks and the LLM-syntax task conflict: an AST allowlist applied to the raw string rejects x^2 and 2x. Recorded the required ordering (normalize -> AST-guard -> parse) rather than listing them as independent checkboxes. - The plan, CLAUDE.md and docs/ all claim non-linear input raises. False whenever a linear term is present: x**2 + x = 4 returns one of two roots with a fabricated linear narrative. Back-substitution cannot catch this, so added a completeness guardrail and a degree check. Corrected two claims the plan made that the code contradicts, both of which had acceptance criteria built on them: - sqrt(2) does not raise ValidationError; complex and parametric input are the real failures, and the parametric one is a step-generation bug that a schema change will not fix. - The stale-example description was wrong about which values differ. Also folded in: the discriminated union moves into M0 so the tool schema breaks once rather than twice; M6 gains the test-gate task its acceptance criterion already assumed; step-text assertions become an M0 acceptance criterion; plus root-logger, error-leak and stale-action-version items. --- plans/PROJECT_PLAN.md | 115 +++++++++++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 41 deletions(-) diff --git a/plans/PROJECT_PLAN.md b/plans/PROJECT_PLAN.md index 140080b..94365bc 100644 --- a/plans/PROJECT_PLAN.md +++ b/plans/PROJECT_PLAN.md @@ -2,13 +2,17 @@ > 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). +> +> *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 (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. **The equation parser executes arbitrary Python** (see M0) — treat every published binary as vulnerable until M0 lands. ## 2. Vision & success criteria @@ -23,50 +27,63 @@ 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. `x**2 + x = 4` returns `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 — and today it is arbitrary code execution.** 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. `parse_expr` `eval()`s the transformed input, so a short payload reaches the Python object graph and runs arbitrary code (verified — see M0). Every solver must treat input as hostile: an AST-level allowlist, size/complexity limits, and a wall-clock timeout. - **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.** Today `value` is forced to `float` (`schemas.py` `Solution.value: float`), which corrupts exact answers (`3*x = 1` returns `0.3333333333333333`, not `1/3`) and raises `TypeError: Cannot convert complex to float` on complex ones (`I*x = 1`). Return exact forms; offer a float alongside, don't replace. *(Requires a schema change — sequenced into M0, not M1, because the guardrail is unachievable while the schema forces a float.)* + - *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. +- [ ] 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. +- [ ] **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. - [ ] 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. **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. +- [ ] **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. +- [ ] **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. +- [ ] **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**. +- [ ] **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. +- [ ] **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.) +- [ ] **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. +- [ ] **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). +- [ ] **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. +- [ ] **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**. + +- [ ] **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. + - [ ] 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. +- [ ] **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. +- [ ] **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. +- [ ] **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. + +**Acceptance:** `pipenv run test` is green 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 +91,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 +118,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 +142,19 @@ Project-level success: **Goal:** frictionless install everywhere; healthy release process. +- [ ] **Gate releases on green tests.** `build.yml` has *no* test job, and M0's test job runs on push/PR only — nothing runs it on a tag push. Add the test job to the release path with `needs: test` on `build`, otherwise the "releases are gated on green tests" acceptance below is unenforced. *(Added in review round 2: this was an acceptance criterion with no task implementing it.)* - [ ] **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 +164,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 +189,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). From d1dc6760cb7909af2d8376487c7117fe8e599807 Mon Sep 17 00:00:00 2001 From: Rudi MK Date: Mon, 3 Aug 2026 06:56:00 +0000 Subject: [PATCH 2/9] Implement Milestone 0: input hardening, correctness and a test net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the code-execution vulnerability and the silent wrong-answer class, replaces float coercion with exact values, and puts a test suite behind all of it. Security (guard.py, sandbox.py) - parse_expr eval()s its transformed input and attribute access passes straight through, so an equation string reached the Python object graph and ran arbitrary code. Restricting the parser namespace does not stop this; the escape is attribute traversal off any SymPy object in scope. Input is now normalised with SymPy's own tokenizer, walked against an AST allowlist, and only then evaluated. Ordering matters: guarding the raw string would reject x^2 (BitXor) and 2x (SyntaxError), the syntax normalisation exists to accept. - Exponent towers and oversized exponents are rejected, since 10**10**8 is pure arithmetic that any syntax-only filter passes and that exhausts memory on eval. Expression size and complexity are bounded too. - Solving runs in a reusable spawned worker process under a wall-clock timeout. A thread cannot be cancelled, so the previous executor could time out the caller but never the work. On overrun the worker is killed and the next request starts a fresh one. Verified in the frozen PyInstaller binary, which is where multiprocessing is most likely to misbehave. Correctness (solver.py, schemas.py) - The old a==0 check only caught polynomials with no linear term, so x**2 + x = 4 returned one of two roots and x**3 - 2*x = 0 dropped ±sqrt(2), each with a fabricated linear derivation. Back-substitution cannot catch this — the returned root satisfies the equation — so degree is now checked before solving and non-linear input is refused. - Solutions are exact: value carries the SymPy form as a string with float_value alongside, so 1/3 stays 1/3 and complex results no longer raise TypeError. - Output is a discriminated union tagged by outcome. No solution, infinitely many, and an unknown variable were all IndexError before. - Steps are generated from the operations actually performed, so a label can no longer contradict the equation printed beside it, and the symbolic-form line no longer renders SymPy's Eq() repr. - Step generation no longer branches on the sign of a possibly-symbolic quantity, which raised TypeError on a*x + b = c. - asyncio.to_thread replaces the deprecated get_event_loop pattern; logging is scoped to this package instead of reconfiguring root; errors no longer forward raw Python exception text to clients. Dependencies - fastmcp pinned to ==2.3.3. Adding dev-packages forces a re-resolve and an unpinned spec jumps to 3.x, whose wheel resolves to a fastmcp_slim namespace package with no Client. The committed lock had always pinned this version; upgrading is separate work. Also adds a CI job running the suite on push and PR with --dev deps, and brings README, docs/ and CLAUDE.md in line with the new contract. --- CLAUDE.md | 34 +- Pipfile | 12 +- Pipfile.lock | 1030 +++++++++++++++++++++++++++----------- README.md | 99 +++- docs/linear-equations.md | 88 +++- guard.py | 210 ++++++++ plans/PROJECT_PLAN.md | 57 ++- pytest.ini | 4 + sandbox.py | 213 ++++++++ schemas.py | 122 +++-- server.py | 120 +++-- solver.py | 312 ++++++++---- tests/test_guard.py | 134 +++++ tests/test_sandbox.py | 97 ++++ tests/test_server.py | 89 ++++ tests/test_solver.py | 260 ++++++++++ 16 files changed, 2350 insertions(+), 531 deletions(-) create mode 100644 guard.py create mode 100644 pytest.ini create mode 100644 sandbox.py create mode 100644 tests/test_guard.py create mode 100644 tests/test_sandbox.py create mode 100644 tests/test_server.py create mode 100644 tests/test_solver.py diff --git a/CLAUDE.md b/CLAUDE.md index a2a9bfa..79074a7 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.yml` — CI: runs the test suite on every push and PR (with `--dev` deps). +- `.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 94365bc..fabf38f 100644 --- a/plans/PROJECT_PLAN.md +++ b/plans/PROJECT_PLAN.md @@ -6,13 +6,17 @@ > > *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. **The equation parser executes arbitrary Python** (see M0) — treat every published binary as vulnerable until M0 lands. +**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 @@ -27,10 +31,10 @@ 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. -- **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. `x**2 + x = 4` returns `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 — and today it is arbitrary code execution.** 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. `parse_expr` `eval()`s the transformed input, so a short payload reaches the Python object graph and runs arbitrary code (verified — see M0). Every solver must treat input as hostile: an AST-level allowlist, size/complexity limits, and a wall-clock timeout. +- **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 (`3*x = 1` returns `0.3333333333333333`, not `1/3`) and raises `TypeError: Cannot convert complex to float` on complex ones (`I*x = 1`). Return exact forms; offer a float alongside, don't replace. *(Requires a schema change — sequenced into M0, not M1, because the guardrail is unachievable while the schema forces a float.)* +- **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. @@ -42,34 +46,39 @@ Project-level success: **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 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. -- [ ] **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. -- [ ] 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. **Correctness bug fixes (in `solver.py` / `schemas.py`)** -- [ ] **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. -- [ ] **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. -- [ ] **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**. -- [ ] **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. -- [ ] **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.) -- [ ] **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. -- [ ] **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). -- [ ] **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. -- [ ] **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. +- [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**. -- [ ] **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. +- [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. - - [ ] 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. -- [ ] **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. -- [ ] **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. -- [ ] **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. + - [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 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. @@ -142,7 +151,7 @@ Project-level success: **Goal:** frictionless install everywhere; healthy release process. -- [ ] **Gate releases on green tests.** `build.yml` has *no* test job, and M0's test job runs on push/PR only — nothing runs it on a tag push. Add the test job to the release path with `needs: test` on `build`, otherwise the "releases are gated on green tests" acceptance below is unenforced. *(Added in review round 2: this was an acceptance criterion with no task implementing it.)* +- [ ] **Gate releases on green tests.** M0 added `.github/workflows/test.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.)* - [ ] **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. 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" From db99e1104db347366d40f8c40efb4f6033254661 Mon Sep 17 00:00:00 2001 From: Rudi MK Date: Mon, 3 Aug 2026 06:56:00 +0000 Subject: [PATCH 3/9] Park the CI test workflow at ci/ pending workflow-scope push access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job itself is ready: it runs `pipenv run test` on every push and PR with `--dev` deps, which the build workflow's bare `pipenv install` would skip. It cannot be committed under .github/workflows/ from this session — GitHub rejects pushes that touch that path without the `workflow` scope, and the contents API returns 404 for the same reason. ci/README.md has the two commands to move it into place. --- ci/README.md | 18 ++++++++++++++++++ ci/test-workflow.yml | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 ci/README.md create mode 100644 ci/test-workflow.yml diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000..67f287f --- /dev/null +++ b/ci/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/ci/test-workflow.yml b/ci/test-workflow.yml new file mode 100644 index 0000000..8a1e170 --- /dev/null +++ b/ci/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: ['**'] + 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 From 45a47ff081b17ae9c65268e3fd01a0c8835b627d Mon Sep 17 00:00:00 2001 From: Rudi MK Date: Mon, 3 Aug 2026 06:56:00 +0000 Subject: [PATCH 4/9] Record the parked CI job in the plan and CLAUDE.md --- CLAUDE.md | 2 +- plans/PROJECT_PLAN.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 79074a7..73f7940 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Symbolic mathematics for LLMs, delivered as an [MCP](https://modelcontextprotoco - `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/test.yml` — CI: runs the test suite on every push and PR (with `--dev` deps). +- `ci/test-workflow.yml` — the CI test job, **not yet installed**. Belongs at `.github/workflows/test.yml`; see `ci/README.md`. Until it is moved, nothing runs the suite automatically. - `.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. diff --git a/plans/PROJECT_PLAN.md b/plans/PROJECT_PLAN.md index fabf38f..7031663 100644 --- a/plans/PROJECT_PLAN.md +++ b/plans/PROJECT_PLAN.md @@ -50,7 +50,7 @@ Project-level success: - [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. +- [ ] 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. **Written but not installed:** the job is parked at `ci/test-workflow.yml` because pushes touching `.github/workflows/` need GitHub's `workflow` scope, which this session's token lacks. See `ci/README.md` — one `git mv` and a push finishes it. **Correctness bug fixes (in `solver.py` / `schemas.py`)** - [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. @@ -80,7 +80,7 @@ Project-level success: - [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 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. +**Acceptance:** `pipenv run test` is green (74 tests) and — once `ci/test-workflow.yml` is moved into `.github/workflows/` — 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. --- @@ -151,7 +151,7 @@ Project-level success: **Goal:** frictionless install everywhere; healthy release process. -- [ ] **Gate releases on green tests.** M0 added `.github/workflows/test.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.)* +- [ ] **Gate releases on green tests.** M0 wrote the test job (parked at `ci/test-workflow.yml`; see `ci/README.md`), 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.)* - [ ] **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. From 7da23aa8f15717224c1ab44cfc5526021b471bfd Mon Sep 17 00:00:00 2001 From: Rudi MK Date: Mon, 3 Aug 2026 07:59:23 +0100 Subject: [PATCH 5/9] Moved CI workflows to the right folder. --- {ci => .github/workflows}/README.md | 0 ci/test-workflow.yml => .github/workflows/test-workflow-yml | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {ci => .github/workflows}/README.md (100%) rename ci/test-workflow.yml => .github/workflows/test-workflow-yml (100%) diff --git a/ci/README.md b/.github/workflows/README.md similarity index 100% rename from ci/README.md rename to .github/workflows/README.md diff --git a/ci/test-workflow.yml b/.github/workflows/test-workflow-yml similarity index 100% rename from ci/test-workflow.yml rename to .github/workflows/test-workflow-yml From e745324eab0d847a36b930d00ea7f513d5069943 Mon Sep 17 00:00:00 2001 From: Rudi MK Date: Mon, 3 Aug 2026 08:02:05 +0100 Subject: [PATCH 6/9] Fixed a typo. --- .github/workflows/{test-workflow-yml => test-workflow.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{test-workflow-yml => test-workflow.yml} (100%) diff --git a/.github/workflows/test-workflow-yml b/.github/workflows/test-workflow.yml similarity index 100% rename from .github/workflows/test-workflow-yml rename to .github/workflows/test-workflow.yml From f41aa58da3b307d694d91fd841572b4ecb014ed8 Mon Sep 17 00:00:00 2001 From: Rudi MK Date: Mon, 3 Aug 2026 07:03:54 +0000 Subject: [PATCH 7/9] Mark the CI test job installed now that it runs The job is live at .github/workflows/test-workflow.yml and reported its first green run on PR #2 (both check runs succeeded). Ticks the M0 checkbox and drops the wording describing it as parked pending workflow-scope access. Records two follow-ups in M6, neither blocking: - it triggers on both push and pull_request, so a branch in this repo runs the suite twice per push - .github/workflows/README.md came across with the move and still says the job is awaiting installation --- CLAUDE.md | 2 +- plans/PROJECT_PLAN.md | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 73f7940..d1f1899 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Symbolic mathematics for LLMs, delivered as an [MCP](https://modelcontextprotoco - `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. -- `ci/test-workflow.yml` — the CI test job, **not yet installed**. Belongs at `.github/workflows/test.yml`; see `ci/README.md`. Until it is moved, nothing runs the suite automatically. +- `.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. diff --git a/plans/PROJECT_PLAN.md b/plans/PROJECT_PLAN.md index 7031663..0b5bae2 100644 --- a/plans/PROJECT_PLAN.md +++ b/plans/PROJECT_PLAN.md @@ -50,7 +50,7 @@ Project-level success: - [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. -- [ ] 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. **Written but not installed:** the job is parked at `ci/test-workflow.yml` because pushes touching `.github/workflows/` need GitHub's `workflow` scope, which this session's token lacks. See `ci/README.md` — one `git mv` and a push finishes it. +- [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`)** - [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. @@ -80,7 +80,7 @@ Project-level success: - [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 — once `ci/test-workflow.yml` is moved into `.github/workflows/` — 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. +**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. --- @@ -151,7 +151,9 @@ Project-level success: **Goal:** frictionless install everywhere; healthy release process. -- [ ] **Gate releases on green tests.** M0 wrote the test job (parked at `ci/test-workflow.yml`; see `ci/README.md`), 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.)* +- [ ] **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.)* +- [ ] **Two CI runs per push.** `test-workflow.yml` triggers on both `push: branches: ['**']` and `pull_request`; for a branch in this repo both fire, so every push runs the suite twice. Narrow `push` to `main` or drop the `pull_request` trigger. +- [ ] **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. From 372f88942ed22a17c14ed1dd0ff440c88c814214 Mon Sep 17 00:00:00 2001 From: Rudi MK Date: Mon, 3 Aug 2026 08:05:58 +0100 Subject: [PATCH 8/9] Fixed the CI workflow so it only runs a single job against a PR. --- .github/workflows/test-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-workflow.yml b/.github/workflows/test-workflow.yml index 8a1e170..e409f17 100644 --- a/.github/workflows/test-workflow.yml +++ b/.github/workflows/test-workflow.yml @@ -5,7 +5,7 @@ name: Test # the job it will depend on. on: push: - branches: ['**'] + branches: [main] pull_request: jobs: From c6f6d271b35969ac45b6b16d087c4f6e35ec6d33 Mon Sep 17 00:00:00 2001 From: Rudi MK Date: Mon, 3 Aug 2026 07:07:16 +0000 Subject: [PATCH 9/9] Mark the duplicate CI run issue resolved Narrowing the push trigger to main leaves pull_request as the only event that fires for branch work, so a push now schedules one run instead of two. Confirmed against the workflow run history: the two prior commits each produced a push run and a pull_request run, and the first commit under the new triggers produced a single pull_request run, green. --- plans/PROJECT_PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/PROJECT_PLAN.md b/plans/PROJECT_PLAN.md index 0b5bae2..3af0616 100644 --- a/plans/PROJECT_PLAN.md +++ b/plans/PROJECT_PLAN.md @@ -152,7 +152,7 @@ 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.)* -- [ ] **Two CI runs per push.** `test-workflow.yml` triggers on both `push: branches: ['**']` and `pull_request`; for a branch in this repo both fire, so every push runs the suite twice. Narrow `push` to `main` or drop the `pull_request` trigger. +- [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).