calc-mcp-server 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- calc_mcp_server-0.1.0/.github/dependabot.yml +21 -0
- calc_mcp_server-0.1.0/.github/workflows/auto-release.yml +90 -0
- calc_mcp_server-0.1.0/.github/workflows/release.yml +90 -0
- calc_mcp_server-0.1.0/.github/workflows/validate.yml +89 -0
- calc_mcp_server-0.1.0/.gitignore +22 -0
- calc_mcp_server-0.1.0/.python-version +1 -0
- calc_mcp_server-0.1.0/AGENTS.md +80 -0
- calc_mcp_server-0.1.0/CHANGELOG.md +31 -0
- calc_mcp_server-0.1.0/CLAUDE.md +3 -0
- calc_mcp_server-0.1.0/LICENSE +21 -0
- calc_mcp_server-0.1.0/PKG-INFO +131 -0
- calc_mcp_server-0.1.0/README.md +107 -0
- calc_mcp_server-0.1.0/docs/README.md +81 -0
- calc_mcp_server-0.1.0/docs/domain/OVERVIEW.md +115 -0
- calc_mcp_server-0.1.0/docs/domain/README.md +8 -0
- calc_mcp_server-0.1.0/docs/superpowers/specs/2026-07-30-calc-mcp-server-design.md +93 -0
- calc_mcp_server-0.1.0/docs/tech/ARCHITECTURE.md +106 -0
- calc_mcp_server-0.1.0/docs/tech/CONVENTIONS.md +83 -0
- calc_mcp_server-0.1.0/docs/tech/README.md +11 -0
- calc_mcp_server-0.1.0/docs/tech/RELEASING.md +82 -0
- calc_mcp_server-0.1.0/docs/tech/SAFE-EVALUATION.md +122 -0
- calc_mcp_server-0.1.0/docs/tech/TECH-STACK.md +101 -0
- calc_mcp_server-0.1.0/docs/tech/TESTING.md +117 -0
- calc_mcp_server-0.1.0/pyproject.toml +81 -0
- calc_mcp_server-0.1.0/scripts/changelog_release.py +160 -0
- calc_mcp_server-0.1.0/server.json +22 -0
- calc_mcp_server-0.1.0/src/calc_mcp_server/__init__.py +15 -0
- calc_mcp_server-0.1.0/src/calc_mcp_server/_version.py +24 -0
- calc_mcp_server-0.1.0/src/calc_mcp_server/const.py +96 -0
- calc_mcp_server-0.1.0/src/calc_mcp_server/evaluator.py +236 -0
- calc_mcp_server-0.1.0/src/calc_mcp_server/py.typed +0 -0
- calc_mcp_server-0.1.0/src/calc_mcp_server/server.py +61 -0
- calc_mcp_server-0.1.0/tests/__init__.py +1 -0
- calc_mcp_server-0.1.0/tests/conftest.py +13 -0
- calc_mcp_server-0.1.0/tests/test_changelog_release.py +177 -0
- calc_mcp_server-0.1.0/tests/test_evaluator.py +174 -0
- calc_mcp_server-0.1.0/tests/test_server.py +44 -0
- calc_mcp_server-0.1.0/tests/test_stdio.py +140 -0
- calc_mcp_server-0.1.0/uv.lock +834 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
# Python dependencies (managed via uv / uv.lock)
|
|
4
|
+
- package-ecosystem: "uv"
|
|
5
|
+
directory: "/"
|
|
6
|
+
schedule:
|
|
7
|
+
interval: "weekly"
|
|
8
|
+
groups:
|
|
9
|
+
python-dependencies:
|
|
10
|
+
patterns:
|
|
11
|
+
- "*"
|
|
12
|
+
|
|
13
|
+
# GitHub Actions used in workflows
|
|
14
|
+
- package-ecosystem: "github-actions"
|
|
15
|
+
directory: "/"
|
|
16
|
+
schedule:
|
|
17
|
+
interval: "weekly"
|
|
18
|
+
groups:
|
|
19
|
+
github-actions:
|
|
20
|
+
patterns:
|
|
21
|
+
- "*"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
name: Auto Release (Dependabot)
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [closed]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
version:
|
|
9
|
+
description: 'Version to release, e.g. v0.3.0. Leave empty to bump the patch.'
|
|
10
|
+
required: false
|
|
11
|
+
type: string
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
tag:
|
|
15
|
+
# Runs for a manual dispatch, or for a merged Dependabot PR from the uv
|
|
16
|
+
# (Python dependency) ecosystem — those change the published package.
|
|
17
|
+
# github-actions bumps do not, so they merge without cutting a release.
|
|
18
|
+
if: >-
|
|
19
|
+
github.event_name == 'workflow_dispatch' ||
|
|
20
|
+
(github.event.pull_request.merged == true &&
|
|
21
|
+
github.event.pull_request.user.login == 'dependabot[bot]' &&
|
|
22
|
+
startsWith(github.event.pull_request.head.ref, 'dependabot/uv/'))
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- name: Generate GitHub App token
|
|
26
|
+
id: app-token
|
|
27
|
+
uses: actions/create-github-app-token@v3
|
|
28
|
+
with:
|
|
29
|
+
app-id: ${{ secrets.GH_ACTION_APP_ID }}
|
|
30
|
+
private-key: ${{ secrets.GH_ACTION_APP_PRIVATE_KEY }}
|
|
31
|
+
- uses: actions/checkout@v7
|
|
32
|
+
with:
|
|
33
|
+
ref: main
|
|
34
|
+
fetch-depth: 0 # need all tags + the merge commit
|
|
35
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
36
|
+
- name: Resolve release version
|
|
37
|
+
id: ver
|
|
38
|
+
env:
|
|
39
|
+
REQUESTED: ${{ inputs.version }}
|
|
40
|
+
run: |
|
|
41
|
+
if [ -n "$REQUESTED" ]; then
|
|
42
|
+
if ! printf '%s' "$REQUESTED" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
43
|
+
echo "::error::version input '$REQUESTED' must look like v1.2.3"
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
next="$REQUESTED"
|
|
47
|
+
echo "Releasing requested version: $next"
|
|
48
|
+
else
|
|
49
|
+
latest=$(git tag -l 'v*' --sort=-v:refname | head -n1)
|
|
50
|
+
latest=${latest:-v0.0.0}
|
|
51
|
+
IFS=. read -r major minor patch <<< "${latest#v}"
|
|
52
|
+
next="v${major}.${minor}.$((patch + 1))"
|
|
53
|
+
echo "Releasing next patch: $next (from ${latest})"
|
|
54
|
+
fi
|
|
55
|
+
if git rev-parse -q --verify "refs/tags/$next" >/dev/null; then
|
|
56
|
+
echo "::error::tag $next already exists"
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
echo "next=$next" >> "$GITHUB_OUTPUT"
|
|
60
|
+
|
|
61
|
+
# Files the release section under the version being cut, so no published
|
|
62
|
+
# version can end up without one. Stdlib-only, so no project install.
|
|
63
|
+
- name: Prepare CHANGELOG
|
|
64
|
+
env:
|
|
65
|
+
NEXT: ${{ steps.ver.outputs.next }}
|
|
66
|
+
run: python3 scripts/changelog_release.py --version "$NEXT"
|
|
67
|
+
|
|
68
|
+
- name: Commit, tag and push
|
|
69
|
+
env:
|
|
70
|
+
NEXT: ${{ steps.ver.outputs.next }}
|
|
71
|
+
REASON: ${{ github.event_name == 'workflow_dispatch' && 'manual dispatch' || format('Dependabot PR {0}', github.event.pull_request.number) }}
|
|
72
|
+
run: |
|
|
73
|
+
git config user.name "github-actions[bot]"
|
|
74
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
75
|
+
|
|
76
|
+
# The script is a no-op when the section already exists (a human may
|
|
77
|
+
# have written it in the release PR), and committing nothing would
|
|
78
|
+
# fail and abort an otherwise valid release.
|
|
79
|
+
if git diff --quiet -- CHANGELOG.md; then
|
|
80
|
+
echo "CHANGELOG.md already had a $NEXT section; nothing to commit."
|
|
81
|
+
else
|
|
82
|
+
git add CHANGELOG.md
|
|
83
|
+
git commit -m "docs: changelog for $NEXT ($REASON)"
|
|
84
|
+
git push origin HEAD:main
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# Tag AFTER the commit so the tagged tree contains its own changelog
|
|
88
|
+
# section; tagging first would ship every release without one.
|
|
89
|
+
git tag -a "$NEXT" -m "Automated release $NEXT ($REASON)"
|
|
90
|
+
git push origin "$NEXT"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
name: Build distributions
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v7
|
|
13
|
+
with:
|
|
14
|
+
fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
|
|
15
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- name: Build sdist and wheel
|
|
19
|
+
run: uv build
|
|
20
|
+
- name: Verify tag matches built version
|
|
21
|
+
run: |
|
|
22
|
+
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
23
|
+
BUILT=$(ls dist/calc_mcp_server-*.tar.gz | sed -E 's|.*/calc_mcp_server-(.*)\.tar\.gz|\1|')
|
|
24
|
+
echo "tag=$TAG_VERSION built=$BUILT"
|
|
25
|
+
if [ "$TAG_VERSION" != "$BUILT" ]; then
|
|
26
|
+
echo "::error::Tag $TAG_VERSION does not match built version $BUILT"
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
- uses: actions/upload-artifact@v7
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/
|
|
33
|
+
|
|
34
|
+
pypi-publish:
|
|
35
|
+
name: Publish to PyPI
|
|
36
|
+
needs: build
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
environment: pypi
|
|
39
|
+
permissions:
|
|
40
|
+
id-token: write # OIDC for PyPI Trusted Publishing
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/download-artifact@v8
|
|
43
|
+
with:
|
|
44
|
+
name: dist
|
|
45
|
+
path: dist/
|
|
46
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
47
|
+
|
|
48
|
+
github-release:
|
|
49
|
+
name: Create GitHub Release
|
|
50
|
+
needs: pypi-publish
|
|
51
|
+
runs-on: ubuntu-latest
|
|
52
|
+
permissions:
|
|
53
|
+
contents: write
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/download-artifact@v8
|
|
56
|
+
with:
|
|
57
|
+
name: dist
|
|
58
|
+
path: dist/
|
|
59
|
+
- name: Create release
|
|
60
|
+
env:
|
|
61
|
+
GH_TOKEN: ${{ github.token }}
|
|
62
|
+
run: |
|
|
63
|
+
gh release create "$GITHUB_REF_NAME" \
|
|
64
|
+
--repo "$GITHUB_REPOSITORY" \
|
|
65
|
+
--title "$GITHUB_REF_NAME" \
|
|
66
|
+
--generate-notes \
|
|
67
|
+
dist/*
|
|
68
|
+
|
|
69
|
+
mcp-registry:
|
|
70
|
+
name: Publish to MCP Registry
|
|
71
|
+
needs: pypi-publish
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
permissions:
|
|
74
|
+
id-token: write # OIDC proves ownership of the io.github.<owner> namespace
|
|
75
|
+
contents: read
|
|
76
|
+
steps:
|
|
77
|
+
- uses: actions/checkout@v7
|
|
78
|
+
- name: Set server.json version from tag
|
|
79
|
+
run: |
|
|
80
|
+
VERSION="${GITHUB_REF_NAME#v}"
|
|
81
|
+
jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' \
|
|
82
|
+
server.json > server.tmp && mv server.tmp server.json
|
|
83
|
+
cat server.json
|
|
84
|
+
- name: Install mcp-publisher
|
|
85
|
+
run: |
|
|
86
|
+
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
|
87
|
+
- name: Authenticate to MCP Registry
|
|
88
|
+
run: ./mcp-publisher login github-oidc
|
|
89
|
+
- name: Publish to MCP Registry
|
|
90
|
+
run: ./mcp-publisher publish
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
name: Validate
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
ruff:
|
|
10
|
+
name: Ruff (lint + format)
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v7
|
|
14
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
enable-cache: true
|
|
18
|
+
# ruff comes from the dev group in the committed uv.lock, so CI lints with
|
|
19
|
+
# the exact version used locally. `pip install ruff` was unpinned, meaning
|
|
20
|
+
# a ruff release could fail the build without any change to this repo.
|
|
21
|
+
# --only-group keeps the project itself out of the env (linting does not
|
|
22
|
+
# need it installed); --no-sync then stops `uv run` from putting it back,
|
|
23
|
+
# which would build via hatch-vcs and so need the full git history.
|
|
24
|
+
- run: uv sync --locked --only-group dev
|
|
25
|
+
- run: uv run --no-sync ruff check .
|
|
26
|
+
- run: uv run --no-sync ruff format . --check
|
|
27
|
+
|
|
28
|
+
test:
|
|
29
|
+
name: Tests
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v7
|
|
33
|
+
with:
|
|
34
|
+
fetch-depth: 0 # hatch-vcs derives the version from git history + tags
|
|
35
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
36
|
+
with:
|
|
37
|
+
python-version: "3.12"
|
|
38
|
+
enable-cache: true
|
|
39
|
+
# --locked installs the committed uv.lock exactly and fails if it has
|
|
40
|
+
# drifted from pyproject.toml, so dependency bumps are actually tested.
|
|
41
|
+
- run: uv sync --locked
|
|
42
|
+
# The whole suite runs here: unlike the sibling servers this one contacts
|
|
43
|
+
# nothing external, so there is no `integration` marker to deselect. That
|
|
44
|
+
# includes the stdio round trip, which is the check that would have caught
|
|
45
|
+
# the import break this repo exists to prevent.
|
|
46
|
+
- run: uv run pytest tests/ -v
|
|
47
|
+
|
|
48
|
+
# Advisory only. Deliberately absent from `gate`'s needs so it can never block
|
|
49
|
+
# a merge — the goal is to surface a forgotten entry while it is still cheap to
|
|
50
|
+
# add, not to add ceremony to dependency bumps or docs-only PRs.
|
|
51
|
+
changelog:
|
|
52
|
+
name: Changelog reminder
|
|
53
|
+
if: >-
|
|
54
|
+
github.event_name == 'pull_request' &&
|
|
55
|
+
github.event.pull_request.user.login != 'dependabot[bot]' &&
|
|
56
|
+
!contains(github.event.pull_request.labels.*.name, 'no-changelog')
|
|
57
|
+
runs-on: ubuntu-latest
|
|
58
|
+
steps:
|
|
59
|
+
- uses: actions/checkout@v7
|
|
60
|
+
with:
|
|
61
|
+
fetch-depth: 0 # need the base ref to diff against
|
|
62
|
+
- name: Warn when src changed without a changelog entry
|
|
63
|
+
env:
|
|
64
|
+
BASE: ${{ github.event.pull_request.base.sha }}
|
|
65
|
+
HEAD: ${{ github.event.pull_request.head.sha }}
|
|
66
|
+
run: |
|
|
67
|
+
changed=$(git diff --name-only "$BASE" "$HEAD")
|
|
68
|
+
if grep -q '^src/' <<< "$changed" && ! grep -qx 'CHANGELOG.md' <<< "$changed"; then
|
|
69
|
+
echo "::warning::This PR changes src/ but not CHANGELOG.md. Add an entry under '## Unreleased', or label the PR 'no-changelog'."
|
|
70
|
+
else
|
|
71
|
+
echo "Changelog check satisfied."
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
gate:
|
|
75
|
+
name: gate
|
|
76
|
+
needs: [ruff, test]
|
|
77
|
+
if: always()
|
|
78
|
+
runs-on: ubuntu-latest
|
|
79
|
+
steps:
|
|
80
|
+
- name: Check validation results
|
|
81
|
+
run: |
|
|
82
|
+
if [[ "${{ needs.ruff.result }}" == "success" && \
|
|
83
|
+
"${{ needs.test.result }}" == "success" ]]; then
|
|
84
|
+
echo "All checks passed"
|
|
85
|
+
exit 0
|
|
86
|
+
else
|
|
87
|
+
echo "One or more checks failed"
|
|
88
|
+
exit 1
|
|
89
|
+
fi
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Calculator MCP Server
|
|
2
|
+
> MCP server for exact arithmetic, usable by LLMs via the Model Context Protocol. One `calculate` tool over a hand-rolled AST allowlist — no `eval()`, no unbounded dependencies.
|
|
3
|
+
|
|
4
|
+
> **Editing this guide:** `AGENTS.md` is the single source of truth for project context, read by all AI
|
|
5
|
+
> coding agents and humans. Keep it concise — put detail in `docs/` and link it. When you change code that
|
|
6
|
+
> alters documented behavior, update the matching `docs/` file in the **same PR** (CodeRabbit enforces
|
|
7
|
+
> this — see [docs/README.md](docs/README.md)).
|
|
8
|
+
|
|
9
|
+
## Quick Reference
|
|
10
|
+
- **Lint**: `ruff check .`
|
|
11
|
+
- **Format**: `ruff format .`
|
|
12
|
+
- **Test**: `pytest tests/ -v`
|
|
13
|
+
- **Run server**: `uvx --from . calc-mcp-server` or `python -m calc_mcp_server.server`
|
|
14
|
+
- **Validate (CI)**: Ruff + pytest (all must pass via the `gate` job)
|
|
15
|
+
- **Release**: run the Auto Release workflow; the version comes from the git tag -- see [RELEASING.md](docs/tech/RELEASING.md)
|
|
16
|
+
|
|
17
|
+
## Where to Find Things
|
|
18
|
+
| I need to... | Read |
|
|
19
|
+
|--------------|------|
|
|
20
|
+
| Understand the architecture | [ARCHITECTURE.md](docs/tech/ARCHITECTURE.md) |
|
|
21
|
+
| Change the allowlist, a cap, or anything security-relevant | [SAFE-EVALUATION.md](docs/tech/SAFE-EVALUATION.md) |
|
|
22
|
+
| Write code that fits conventions | [CONVENTIONS.md](docs/tech/CONVENTIONS.md) |
|
|
23
|
+
| Know the tech stack | [TECH-STACK.md](docs/tech/TECH-STACK.md) |
|
|
24
|
+
| Write or run tests | [TESTING.md](docs/tech/TESTING.md) |
|
|
25
|
+
| Cut a release, or add a changelog entry | [RELEASING.md](docs/tech/RELEASING.md) |
|
|
26
|
+
| Understand the domain and why this repo exists | [OVERVIEW.md](docs/domain/OVERVIEW.md) |
|
|
27
|
+
|
|
28
|
+
## Architecture Overview
|
|
29
|
+
MCP presentation layer over a pure, dependency-free evaluator. Purely functional — no classes outside
|
|
30
|
+
the `MCPServer` instance and the exception hierarchy. All code lives in `src/calc_mcp_server/`.
|
|
31
|
+
|
|
32
|
+
- `server.py` -- MCPServer tool registration (1 tool), stdio entry point, turns `CalculatorError` into an `Error:` line
|
|
33
|
+
- `evaluator.py` -- parses, walks the AST against the allowlist, enforces the caps, renders the result
|
|
34
|
+
- `const.py` -- operator aliases, name/function allowlists, the four caps, float precision
|
|
35
|
+
|
|
36
|
+
Data flow: MCP tool call -> `server.py:calculate` -> `evaluator.evaluate()` (normalize aliases ->
|
|
37
|
+
`ast.parse` -> allowlist walk) -> `evaluator.format_result()` -> string.
|
|
38
|
+
|
|
39
|
+
See [Architecture](docs/tech/ARCHITECTURE.md) for module boundaries and data flow detail.
|
|
40
|
+
|
|
41
|
+
## Tech Stack
|
|
42
|
+
- Python 3.12+, `from __future__ import annotations` in every file
|
|
43
|
+
- `mcp[cli]` (`mcp.server.MCPServer`) for MCP server framework -- v2 line, pinned `>=2,<3`
|
|
44
|
+
- **Exactly one runtime dependency**, upper-bounded. This is the point of the repo -- see Structural Risks
|
|
45
|
+
- `ruff` for linting/formatting, `pytest` + `pytest-asyncio` for testing
|
|
46
|
+
- `uv` for environment management, `hatchling` + `hatch-vcs` build backend
|
|
47
|
+
- GitHub Actions CI (validate on push/PR)
|
|
48
|
+
|
|
49
|
+
See [Tech Stack](docs/tech/TECH-STACK.md) for full detail.
|
|
50
|
+
|
|
51
|
+
## Core Conventions
|
|
52
|
+
- Constants centralized in `const.py` -- no inline magic values, and the allowlists are written out rather than derived from `math`'s contents
|
|
53
|
+
- Logger: `_LOGGER = logging.getLogger(__name__)` with `%s` formatting (not f-strings)
|
|
54
|
+
- Import order: `__future__` -> stdlib -> third-party -> local
|
|
55
|
+
- `evaluator.py` raises typed `CalculatorError` subclasses; `server.py` catches them and returns a short `Error:` line -- the tool never raises
|
|
56
|
+
- Never coerce integers to float
|
|
57
|
+
|
|
58
|
+
See [Conventions](docs/tech/CONVENTIONS.md) for naming tables and full rules.
|
|
59
|
+
|
|
60
|
+
## Business Domain
|
|
61
|
+
A calculator MCP server for LLM voice agents, replacing the abandoned and broken
|
|
62
|
+
`mcp-server-calculator`. One tool, `calculate`, taking an expression string. Integer results are exact
|
|
63
|
+
at any size; floats render at 12 significant digits. Evaluation is guarded against both code execution
|
|
64
|
+
and resource exhaustion.
|
|
65
|
+
|
|
66
|
+
See [Domain Overview](docs/domain/OVERVIEW.md) for the tool contract, the supported expression surface,
|
|
67
|
+
and why this repo exists.
|
|
68
|
+
|
|
69
|
+
## Structural Risks
|
|
70
|
+
- **The server key `calculator` and the tool name `calculate` are load-bearing.** Home Assistant agent prompts route to `calculator__calculate` and an existing HA config entry points at `/servers/calculator/sse`. Renaming either breaks live voice agents -- see [OVERVIEW.md](docs/domain/OVERVIEW.md)
|
|
71
|
+
- **Never let a dependency go unbounded.** An unbounded `mcp>=1.4.1` in the package this replaces is what caused the outage this repo exists to prevent; `uv.lock` must be regenerated in the same commit as any `pyproject.toml` dependency change or `uv sync --locked` fails CI
|
|
72
|
+
- Widening `ALLOWED_FUNCTIONS` widens the attack surface -- any addition needs a cap review, since `factorial` needed one and `pow` needed another
|
|
73
|
+
- The caps in `const.py` are tuned to CPython's 4300-digit int-to-string limit; a runtime that changes that limit makes `MAX_RESULT_DIGITS` arbitrary rather than principled
|
|
74
|
+
- `MathError` wraps the stdlib's own message, whose wording differs between Python versions (3.12 says "math domain error", 3.14 says "expected a nonnegative input") -- tests assert the type, not the text
|
|
75
|
+
- Tool functions are called directly in tests, which relies on `@mcp.tool()` returning the function undecorated. That held across the v1 -> v2 migration; a future SDK returning a wrapper breaks the tests loudly rather than silently
|
|
76
|
+
|
|
77
|
+
## Detailed Guides
|
|
78
|
+
- [Technical Context](docs/tech/README.md) -- architecture, safe evaluation, tech stack, conventions, testing
|
|
79
|
+
- [Domain Context](docs/domain/README.md) -- tool contract, expression surface, why this exists
|
|
80
|
+
- [Documentation Guide](docs/README.md) -- how to maintain these docs
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
The version of a release is derived from its git tag by `hatch-vcs`; there is no version string in the
|
|
4
|
+
source tree. Add entries under `## Unreleased` as you go — the release workflow moves them under the
|
|
5
|
+
version being cut, so you never rename that heading by hand. See
|
|
6
|
+
[docs/tech/RELEASING.md](docs/tech/RELEASING.md).
|
|
7
|
+
|
|
8
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
## 0.1.0 - 2026-07-30
|
|
11
|
+
|
|
12
|
+
- Added: initial release. A single `calculate` tool evaluating arithmetic expressions against a
|
|
13
|
+
hand-rolled AST allowlist, replacing the abandoned `mcp-server-calculator` (last commit 2025-05-10),
|
|
14
|
+
whose unbounded `mcp>=1.4.1` made `uvx …@latest` resolve the v2 SDK, crash on the removed
|
|
15
|
+
`mcp.server.fastmcp`, and take the whole MCP proxy down with it.
|
|
16
|
+
- Added: resource caps the incumbent lacked — expression length, AST nesting depth, exponentiation
|
|
17
|
+
result size and factorial argument. `9**9**9` hangs the incumbent for over five seconds; here it is
|
|
18
|
+
rejected before any bignum work, from an estimate on the operands. See
|
|
19
|
+
[SAFE-EVALUATION.md](docs/tech/SAFE-EVALUATION.md).
|
|
20
|
+
- Added: integer results stay exact at any size. The only actively-maintained alternative (the npm
|
|
21
|
+
`@cyanheads/calculator-mcp-server`) computes in float64 and returns `121932631112635260` for
|
|
22
|
+
`123456789 * 987654321` — off by 9, in a tool that exists so the model does not have to approximate.
|
|
23
|
+
- Added: an end-to-end stdio round-trip test that spawns the real entry point and speaks JSON-RPC to it.
|
|
24
|
+
A unit-tested evaluator behind broken MCP wiring is precisely the failure that motivated this repo.
|
|
25
|
+
- Changed: floats render at 12 significant digits, so `0.1 + 0.2` reads `0.3` rather than
|
|
26
|
+
`0.30000000000000004`. Whole floats keep their `.0`, so `8 / 2` stays visibly distinct from `4`.
|
|
27
|
+
- Fixed: the CI test step no longer passes `-m "not integration"`. This repo contacts nothing external
|
|
28
|
+
and so defines no `integration` marker, and the flag disagreed with the `pytest tests/ -v` documented
|
|
29
|
+
in `AGENTS.md` and `TESTING.md` — the kind of drift that makes CI look like it covers more than it
|
|
30
|
+
does.
|
|
31
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stefan Lettmayer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: calc-mcp-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for exact arithmetic: a single safe calculator tool over an AST allowlist, with no unbounded dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/slettmayer/calc-mcp-server
|
|
6
|
+
Project-URL: Repository, https://github.com/slettmayer/calc-mcp-server
|
|
7
|
+
Project-URL: Issues, https://github.com/slettmayer/calc-mcp-server/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/slettmayer/calc-mcp-server/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Stefan Lettmayer
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: arithmetic,calculator,home-assistant,math,mcp,model-context-protocol,safe-eval
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: mcp[cli]<3,>=2
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Calculator MCP Server
|
|
26
|
+
|
|
27
|
+
An MCP server that does arithmetic, exactly — so an LLM does not have to do it mentally.
|
|
28
|
+
|
|
29
|
+
One tool, `calculate`, evaluating expressions against a hand-rolled AST allowlist. No `eval()`, no
|
|
30
|
+
`sympy`, no unbounded dependencies.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
2 + 3 * (4 - 1) / 2 ** 2 -> 4.25
|
|
34
|
+
sqrt(16) + sin(pi/2) -> 5.0
|
|
35
|
+
123456789 * 987654321 -> 121932631112635269
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Why this exists
|
|
39
|
+
|
|
40
|
+
The widely-used `mcp-server-calculator` package is abandoned (last commit May 2025) and declares
|
|
41
|
+
`mcp>=1.4.1` with no upper bound. When the MCP Python SDK released v2 and removed
|
|
42
|
+
`mcp.server.fastmcp`, every `uvx …@latest` install of it started crashing on import — which took down
|
|
43
|
+
the entire MCP proxy hosting it, and every other server alongside it.
|
|
44
|
+
|
|
45
|
+
No maintained Python replacement exists. The one actively-maintained npm calculator computes in
|
|
46
|
+
float64, so `123456789 * 987654321` comes back as `121932631112635260` — off by 9. That is a poor
|
|
47
|
+
trait in a tool whose whole purpose is that the model should not be doing the arithmetic itself.
|
|
48
|
+
|
|
49
|
+
So this server:
|
|
50
|
+
|
|
51
|
+
- **bounds its one dependency** (`mcp[cli]>=2,<3`) — the failure above cannot recur here;
|
|
52
|
+
- **keeps integers exact** at any size, never coercing to float;
|
|
53
|
+
- **bounds resource use**, not just code execution — see below.
|
|
54
|
+
|
|
55
|
+
## Safety
|
|
56
|
+
|
|
57
|
+
Two problems, and most calculator servers only solve the first.
|
|
58
|
+
|
|
59
|
+
**Code execution.** Expressions are parsed with `ast.parse` and walked against an explicit allowlist of
|
|
60
|
+
node types. `Attribute` is not on it, so `(1).__class__.__bases__` is rejected. A `Call` is only
|
|
61
|
+
evaluated when its target is a bare name in the function allowlist, so `__import__('os').system(…)` is
|
|
62
|
+
rejected before any argument is even evaluated.
|
|
63
|
+
|
|
64
|
+
**Resource exhaustion.** An allowlist alone still lets `9**9**9` occupy the process for minutes on
|
|
65
|
+
unbounded bignum exponentiation — the incumbent hangs for over five seconds on it. Four caps close
|
|
66
|
+
that: expression length (500 chars), nesting depth (32), result size (4300 digits, checked on the
|
|
67
|
+
operands *before* exponentiating), and factorial argument (1000).
|
|
68
|
+
|
|
69
|
+
Full detail in [docs/tech/SAFE-EVALUATION.md](docs/tech/SAFE-EVALUATION.md).
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uvx calc-mcp-server
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Pin it. Do not add `@latest` — that is how the package this replaces broke.
|
|
78
|
+
|
|
79
|
+
## Configure
|
|
80
|
+
|
|
81
|
+
As a stdio MCP server:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"mcpServers": {
|
|
86
|
+
"calculator": {
|
|
87
|
+
"command": "uvx",
|
|
88
|
+
"args": ["calc-mcp-server"]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## The `calculate` tool
|
|
95
|
+
|
|
96
|
+
| Argument | Type | Description |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `expression` | `str` | The expression to evaluate |
|
|
99
|
+
|
|
100
|
+
Returns the result as a string, or a line starting with `Error: ` explaining why the expression was
|
|
101
|
+
rejected. The tool never raises, so a bad expression is an answer the agent can read back rather than a
|
|
102
|
+
tool-call failure.
|
|
103
|
+
|
|
104
|
+
**Operators** — `+` `-` `*` `/` `//` `%` `**`, parentheses, unary `+`/`-`. `^` is accepted as a power
|
|
105
|
+
operator, and `×` `·` `÷` `−` are accepted as their ASCII equivalents (speech-to-text produces them).
|
|
106
|
+
|
|
107
|
+
**Constants** — `pi`, `e`, `tau`.
|
|
108
|
+
|
|
109
|
+
**Functions** — `abs` `round` `min` `max` `sqrt` `exp` `log` `log2` `log10` `sin` `cos` `tan` `asin`
|
|
110
|
+
`acos` `atan` `atan2` `degrees` `radians` `hypot` `floor` `ceil` `factorial` `gcd` `lcm`.
|
|
111
|
+
|
|
112
|
+
**Results** — integer arithmetic returns an exact integer of any size. Floats are rendered at 12
|
|
113
|
+
significant digits, which removes IEEE-754 representation noise (`0.1 + 0.2` reads `0.3`, not
|
|
114
|
+
`0.30000000000000004`) while keeping far more precision than a calculator result is used at. A whole
|
|
115
|
+
float keeps its `.0`, so `8 / 2` reads `4.0` and stays distinct from the exact integer `4`.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
uv sync
|
|
121
|
+
uv run pytest tests/ -v
|
|
122
|
+
uv run ruff check .
|
|
123
|
+
uv run ruff format .
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
See [AGENTS.md](AGENTS.md) for the project guide and [docs/](docs/README.md) for the full
|
|
127
|
+
documentation set.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT — see [LICENSE](LICENSE).
|