calculator-mcp
README.md
# Calculator MCP Server
calculator-mcp is a small MCP server that exposes 16 arithmetic operations as
callable tools for an LLM. It contains no math of its own — every tool is a thin
synchronous wrapper that logs its arguments and delegates to a shared Calculator
instance from the external calculator-lib-rubens package.
## Features
16 calculator tools available via MCP:
**Two-operand operations**: `add`, `subtract`, `multiply`, `divide`, `power`,
`nth_root`, `modulo`, `floor_divide`
**Single-operand operations**: `sqrt`, `absolute`, `floor`, `ceil`, `log10`,
`ln`, `exp`
**Rounding**: `round_number` (with configurable decimal places)
## Prerequisites
- Python 3.14+
- [Poetry](https://python-poetry.org/) for dependency management
- Docker 23+ with the buildx and compose plugins (optional, for running the
server in a container)
## Installation
```bash
poetry install
```
## Configuration
The server ships with a default `config.yaml` bundled inside the package. To
override it, set the `CALCULATOR_MCP_CONFIG` environment variable to the
absolute path of your custom configuration file:
```bash
export CALCULATOR_MCP_CONFIG=/path/to/your/config.yaml
```
When `CALCULATOR_MCP_CONFIG` is not set, the bundled default is used
automatically.
The configuration file has two sections:
```yaml
server:
transport: "http" # "stdio" or "http"
host: "0.0.0.0" # Host for HTTP transport (0.0.0.0 = all interfaces)
port: 9000 # Port for HTTP transport
timeout: 10 # Tool execution timeout in seconds
client:
is_oauth: false # Enable OAuth authentication
url: "http://127.0.0.1:9000/mcp" # Server URL for HTTP transport
token_dir: "/home/user/.fastmcp" # OAuth token storage directory
callback_port: 10000 # OAuth callback server port
```
The `logging` section controls Python logging via `dictConfig`. The default
configuration logs `calculator_mcp` messages at `INFO` level to stderr.
## Running the Server
There are three ways to start the server:
**Console script** (installed by Poetry):
```bash
# requires poetry to be installed
poetry install # only needed once
poetry run calculator-mcp
```
**As a Python module:**
```bash
# requires poetry to be installed
poetry install # only needed once
eval $(poetry env activate)
python -m calculator_mcp
deactivate
```
**With a custom configuration:**
```bash
# requires poetry to be installed
poetry install # only needed once
export CALCULATOR_MCP_CONFIG=/path/to/your/config.yaml
poetry run calculator-mcp
```
## Running the Server with Docker
The server can also run as a container. See [DOCKER.md](DOCKER.md) for the full
reference.
**Build the image:**
```bash
# requires Docker to be installed and running
docker build --build-arg VERSION="$(poetry version -s)" \
-t "calculator-mcp:$(poetry version -s)" -t calculator-mcp:latest .
```
**Run the container:**
```bash
docker run -d --name calculator-mcp -p 9000:9000 \
--restart unless-stopped "calculator-mcp:$(poetry version -s)"
```
**Verify it is up:**
```bash
curl http://127.0.0.1:9000/health # -> OK
```
**With Docker Compose:**
```bash
docker compose up --build -d
docker compose logs -f calculator-mcp
docker compose down
```
Notes:
- The container listens on `0.0.0.0:9000`, per the bundled `config.yaml`.
- The MCP endpoint is `http://127.0.0.1:9000/mcp`.
- The server runs as a non-root user (`uid=1001`).
- To use a custom configuration, mount it and set `CALCULATOR_MCP_CONFIG`
to the mounted path.
## Running the Client
A sample integration test client is provided in `tests/integration/client.py`
to demonstrate the MCP protocol with the server. It lists all available tools
and calls each one with sample arguments.
**Important:** The server must be running before you start the client. See
[Running the Server](#running-the-server) above.
**Run the client:**
```bash
# requires poetry to be installed
poetry install # only needed once
eval $(poetry env activate)
python tests/integration/client.py
deactivate
```
## Add MCP Server to Claude Code
- Add the MCP server to Claude Code using project scope. The file `.mcp.json` is
added to the project root folder:
```bash
# It is assumed that the MCP server is running on http://127.0.0.1:9000
cd $(git rev-parse --show-toplevel) || exit
claude mcp add \
--scope project \
--transport http \
calculator-mcp http://127.0.0.1:9000/mcp
```
## Style Guide
This project follows the
[Google Python Style Guide](https://google.github.io/styleguide/pyguide.html).
All docstrings use the Google format with `Args:`, `Returns:`, and `Raises:`
sections where applicable. Compliance is enforced via pylint, black, isort, and
mypy.
## Development
```bash
# Run tests
poetry run pytest
# Run tests with coverage
poetry run pytest --cov
# Lint
poetry run pylint src/calculator_mcp
# Format code
poetry run black src tests
poetry run isort src tests
# Type check
poetry run mypy src/calculator_mcp
```
All four tools — `pylint`, `black`, `isort` and `mypy` — are declared in the
`dev` dependency group, so `poetry install` is all that is needed to run them.
## Project Structure
```
calculator-mcp/
├── pyproject.toml # Project metadata, dependencies, tool config
├── Dockerfile # Multi-stage container image build
├── .dockerignore # Files excluded from the build context
├── docker-compose.yml # Compose service definition
├── DOCKER.md # Container build and deployment guide
├── src/calculator_mcp/
│ ├── __init__.py
│ ├── __main__.py # python -m calculator_mcp entry point
│ ├── config.py # Loads config.yaml, configures logging
│ ├── config.yaml # Bundled default runtime config
│ ├── main.py # CLI entry point, signal handling
│ └── server.py # FastMCP server with 16 @mcp.tool functions
└── tests/
├── __init__.py
├── test_config.py # Unit tests for config module
├── test_main.py # Unit tests for main module
├── test_server.py # Integration tests via MCP Client
├── test_tools.py # Unit tests for tool functions
└── integration/
├── __init__.py
└── client.py # Sample MCP client with OAuth support
```
## License
See [LICENSE](LICENSE) for details.
## MCP Protocol
The MCP protocol, briefly
MCP (Model Context Protocol) is an open standard for connecting LLM applications
to external context and capabilities. The shape of it:
Wire format. JSON-RPC 2.0 — requests (with id), responses, and notifications.
Nothing MCP-specific about the envelope; the protocol is the vocabulary of
methods on top of it.
### MCP Participants
Participants. A host (Claude Code, Claude Desktop, an agent app) runs one client
per connected server. The connection is 1:1 and stateful.
### MCP Lifecycle
Lifecycle:
1. client initializes request → server replies with its protocol
version,capabilities, and serverInfo
2. client sends the notifications/initialized notification → normal operation.
3. Capabilities are negotiated at that handshake, so neither side calls methods
the other never advertised.
### MCP Server Primitives
Server primitives. Three things a server can offer:
- Tools — model-controlled functions the LLM decides to invoke (tools/list,
tools/call).
- Resources — application-controlled data addressed by URI (resources/list,
resources/read).
- Prompts — user-controlled templates, typically surfaced as slash commands.
### MCP Client Primitives
Client primitives. Sampling (server asks the client's model to complete
something), roots (filesystem scope), and elicitation (server asks the user a
question).
### MCP Transport
Transports. Two standard ones:
- stdio (server is a subprocess; JSON-RPC over newline-delimited stdin/stdout,
with stderr free for logs) and
- Streamable HTTP (a single /mcp endpoint taking POSTs, optionally upgrading to
SSE for server→client streaming, with sessions tracked by an Mcp-Session-Id
header). Remote HTTP servers authenticate with OAuth 2.1.
## How This Project Implements the MCP Protocol
It doesn't hand-roll any of the protocol. FastMCP (fastmcp >=3.4.7) supplies the
JSON-RPC layer, the initialize handshake, capability advertisement, session
management, and both transports. The project's job is to declare what it serves.
Tool declaration is derived from Python
Each @mcp.tool-decorated function is turned into an MCP tool descriptor
automatically:
- The function name becomes the tool name (add, nth_root, round_number).
- The type hints (a: float, b: float, decimals: int = 0) become the tool's JSON
Schema inputSchema, including which parameters are required — that's what lets
FastMCP reject bad arguments before your code runs.
- The Google-style docstring becomes the tool description the model reads when
deciding what to call. This is why src/calculator_mcp/server.py treats
docstrings as a contract rather than internal commentary.
- A raised ValueError (divide by zero, sqrt of a negative) is converted into a
JSON-RPC tool error — tests/test_server.py asserts this by expecting ToolError
from divide (1, 0).
#### Protocol-level metadata the server sets explicitly
mcp = FastMCP ("Calculator MCP Server", version=_VERSION, instructions="...",
website_url=_HOMEPAGE)
instructions is part of the initialize response — server-level guidance the host
can put in front of the model, summarizing the tool families so it doesn't have
to infer them tool by tool.
Every tool also carries MCP behavioral annotations:
annotations={"readOnlyHint": True, "idempotentHint": True, "openWorldHint":
False}
These are hints to the host, not enforcement: nothing is mutated, calling twice
is safe, and the tool touches no external world. Hosts use them to decide
whether a call needs a confirmation prompt — which is why a calculator can run
unattended. timeout=_TIMEOUT (10s from config) bounds each call.
### Transport
config.yaml selects the transport and main.py acts on it:
if transport == "http":
mcp.run (transport="http", host=get_host (), port=get_port ())
else:
mcp.run (transport="stdio")
The shipped default is http on 0.0.0.0:9000 — bound to all interfaces
deliberately so the server is reachable from outside the container. The stdio
path is what a host would use to launch it as a subprocess. Note the logging
config sends handlers to ext://sys.stderr; under stdio that's mandatory, since
stdout carries the JSON-RPC frames.
/health is registered via @mcp.custom_route — a plain Starlette route on the
same ASGI app, outside the MCP protocol. Container orchestration needs a probe
that doesn't speak JSON-RPC. tests/test_server.py::test_health_check exercises
it in-process with httpx.ASGITransport over mcp.http_app (), no port required.
### The client side
tests/integration/client.py is the mirror image — a real MCP client built from
the same config module. It does the full round trip: client.ping (),
client.list_tools (), then call_tool on each discovered tool with sample
arguments. For the deployed instance
at https://rubens-calculator-mcp.fastmcp.app/mcp it wraps
fastmcp.client.auth.OAuth, persisting tokens in a DiskStore behind a
FernetEncryptionWrapper keyed from OAUTH_STORAGE_ENCRYPTION_KEY, with a fixed
callback port so the OAuth redirect URI stays stable across runs.
The unit tests use the same Client (mcp) API against the in-process server
object, so tests go through genuine MCP tool dispatch rather than calling the
Python functions directly.
### Observability
config.yaml pre-wires loggers for the protocol internals —
mcp.server.lowlevel.server, mcp.server.streamable_http,
mcp.server.streamable_http_manager, mcp.client.streamable_http, plus
httpx/httpcore — each at INFO with a comment saying to flip it to DEBUG to see
the raw JSON-RPC messages or HTTP wire traffic. That's the debugging path when a
host and this server disagree.This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues