Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions .github/workflows/test-workflow.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Test

# The build workflow runs only on tag pushes, so nothing checked ordinary
# commits before this existed. Gating releases on tests is M6's job; this is
# the job it will depend on.
on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12"]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pipenv"
- name: Install pipenv
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade pipenv
# `--dev` matters: the build workflow's bare `pipenv install` skips
# dev-packages, so pytest would not be present.
- name: Install dependencies
run: pipenv install --dev --deploy
- name: Run tests
run: pipenv run test
34 changes: 22 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,51 @@ Symbolic mathematics for LLMs, delivered as an [MCP](https://modelcontextprotoco
**Stack:** Python 3.12, `pipenv`, [FastMCP](https://github.com/jlowin/fastmcp), SymPy, PyInstaller (for standalone binaries). Remote: `github.com/EquationsCP/equationscp` (CI/releases live under the `EquationsCP` org).

## Files
- `server.py` — FastMCP server. Defines the `solve_linear_equation` async tool. Runs SymPy in a thread pool executor (`run_in_executor`) so the async loop isn't blocked. Server name is `sympy-solver`. `__main__` calls `server.run()` (stdio by default).
- `solver.py` — the math. `sympy_solve_linear_equation(equation_str, variable_str)` parses `"lhs = rhs"`, solves with `sp.solve`, and hand-builds the step list (subtract constant, divide by coefficient). `format_solution(...)` shapes the dict for the output schema.
- `schemas.py` — Pydantic models: `EquationInput` (equation, variable), `Solution` (variable, value), `EquationOutput` (solution, steps).
- `server.py` — FastMCP server. Defines the `solve_linear_equation` async tool, dispatching via `asyncio.to_thread`. Server name is `sympy-solver`. Every failure is returned as a typed outcome, never raised. `__main__` calls `multiprocessing.freeze_support()` then `server.run()` (stdio by default).
- `guard.py` — untrusted-input gate. `safe_parse(...)` normalizes LLM syntax (`x^2`, `2x`) with SymPy's tokenizer, walks the resulting AST against an allowlist, and only then evaluates. **`parse_expr` on raw input is arbitrary code execution** — see the module docstring before touching this.
- `solver.py` — the math. `_solve(equation_str, variable_str)` returns a dict matching one `EquationOutput` member and raises `SolverError` for typed failures. Checks *degree before solving* (completeness) and substitutes the answer back into the original equation (soundness). Steps are generated from the operations actually performed.
- `sandbox.py` — wall-clock bound. Solves run in a reusable spawned worker process that is killed and replaced on timeout, because a thread cannot be cancelled.
- `schemas.py` — Pydantic models. `EquationOutput` is a discriminated union tagged by `outcome`: `solved` | `no_solution` | `infinite_solutions` | `error`.
- `tests/` — pytest suite (`pipenv run test`). Happy-path tests pin the full step list, not just the value.
- `Pipfile` — deps + the `[scripts]` used below. `Pipfile.lock` is committed.
- `server.spec` — PyInstaller spec; produces a single-file `console` binary named `server`.
- `Taskfile.yaml` — release automation (see below).
- `Versionfile` — current version string (e.g. `v0.1.0`), read by the Taskfile to build tags.
- `.github/workflows/build.yml` — CI: on any tag push, builds binaries for linux-amd64, linux-arm64, darwin-arm64 and cuts a **prerelease** GitHub release with the artifacts attached.
- `.github/workflows/test-workflow.yml` — CI: runs `pipenv install --dev` then `pipenv run test` on every push and PR. Note it fires on both `push` and `pull_request`, so a branch in this repo gets two runs per push.
- `.github/workflows/build.yml` — CI: on any tag push, builds binaries for linux-amd64, linux-arm64, darwin-arm64 and cuts a **prerelease** GitHub release with the artifacts attached. Not yet gated on tests (M6).
- `docs/` — canonical, in-repo documentation (see below).
- `plans/PROJECT_PLAN.md` — the milestone-based project plan.

## The one tool it exposes
`solve_linear_equation` — input `{equation, variable}`, output `{solution: {variable, value}, steps: [...]}`.
`solve_linear_equation` — input `{equation, variable}`, output tagged by `outcome`.

```json
// in
{ "equation": "2*x + 3 = 7", "variable": "x" }
// out
{ "solution": { "variable": "x", "value": 2.0 },
"steps": ["Original equation: 2*x + 3 = 7", "...", "Solution: x = 2.0"] }
{ "outcome": "solved",
"solution": { "variable": "x", "value": "2", "float_value": 2.0 },
"steps": ["Original equation: 2*x + 3 = 7", "...", "Solution: x = 2"] }
```

Only **linear** equations are supported today. `value` is always coerced to `float`. Non-linear input (coefficient of the variable is 0) raises `ValueError`.
Only **linear** equations are supported. `solution.value` is the **exact** form as a string (`"1/3"`, `"sqrt(2)/2"`, `"-I"`); `float_value` is the approximation alongside it, `null` when no real float exists.

Anything whose degree in the variable isn't 1 is refused with `error: "not_linear"` — *including* equations that have a linear term (`x**2 + x = 4`). This matters: the earlier coefficient-based check missed those and silently returned one root of a quadratic. Back-substitution cannot catch that class of bug, only the degree check can.

## Running it
```bash
pipenv install # first-time setup
pipenv install --dev # first-time setup (--dev for the test suite)
pipenv run stdio-server # fastmcp run server.py (stdio — default MCP transport)
pipenv run http-server # SSE on 0.0.0.0:4242
pipenv run http-server # SSE on 127.0.0.1:4242 (no auth — keep it on loopback)
pipenv run dev-server # fastmcp dev server.py
pipenv run inspector # MCP Inspector UI at http://127.0.0.1:6274 (needs npx)
pipenv run build # pyinstaller server.spec -> dist/server
pipenv run test # pytest
```

Note: `README.md` occasionally references `src/server.py`, but the files actually live at the repo root (`server.py`, not `src/server.py`). Trust the root paths.
All files live at the repo root (`server.py`, not `src/server.py`).

`fastmcp` is pinned to `==2.3.3` in the Pipfile. An unpinned spec re-resolves to 3.x, a different API whose wheel resolves to a `fastmcp_slim` namespace package with no `Client`. Upgrading is its own task.

## Cutting a release
Bump `Versionfile`, then use the Taskfile (requires [Task](https://taskfile.dev)):
Expand All @@ -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`.
12 changes: 10 additions & 2 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading