sars-dates-mcp
by TyReyGit
README.md
# sars-dates-mcp
An MCP (Model Context Protocol) server exposing a curated dataset of South
African tax deadlines — provisional tax, individual filing season, VAT, PAYE,
and employer reconciliations. Built to demonstrate tool-contract design, not
just to answer a date question: typed schemas, error messages that teach the
calling agent what to do next, and a response-size budget applied before it
was ever needed.
**Status:** Complete for its current scope. Two tools, 41 tests, strict mypy,
verified against MCP Inspector CLI.
~745 lines across `server.py` (172 — the two MCP tools and their formatting),
`deadlines.py` (191 — the data layer, with no MCP dependency at all), and
`__init__.py`, plus 41 tests covering both layers, including an explicit
clock seam so date-dependent tests don't depend on when they're run.
## Why this exists
MCP servers *are* harness components — tool interfaces with contracts, the
same way a REST endpoint or a function signature is a contract. This one is
deliberately small in scope (13 curated deadlines) so the engineering
discipline is the point: schema design, error handling, response budgeting,
and a documented security posture, not dataset size.
## The two tools
**`get_upcoming_deadlines(within_days, category=None)`** — deadlines falling
within the next N days from today. Use when the question is time-bounded:
"what's due in the next 30 days?"
**`list_deadlines(category=None)`** — every known deadline, optionally
filtered by category, including ones already past. Use when the question
isn't time-bounded: "what are all the VAT dates you know about?"
Both accept an optional `category`, typed as a `Literal` rather than a bare
string:
```python
Category: TypeAlias = Literal[
"provisional_tax", "filing_season", "vat", "paye", "emp501"
]
```
This isn't cosmetic. FastMCP turns a `Literal` into an `enum` in the tool's
JSON schema, so a calling agent sees the valid values up front instead of
guessing at a string and finding out it was wrong. A test asserts this
`Literal` and the runtime validation list (`VALID_CATEGORIES`) can't drift
apart.
**Errors teach, they don't just fail.** An invalid category doesn't return a
bare exception — it returns a message listing the valid categories and how to
omit the filter entirely. That's the same principle used in `bare-agent`'s
permission-denial messages: a tool response is part of the prompt, so it
should steer the next action, not just report failure.
## Data model
Each deadline is a `Deadline`: `id`, `category`, `title`, `date`,
`recurrence`, `notes`, `source_url`, `source_detail`, `last_verified`. The
dataset lives in `data/deadlines.json`, loaded and strictly validated at
startup — a missing or wrong-typed field fails loudly (`ValueError`) rather
than silently producing a malformed `Deadline`.
Every deadline carries its own provenance: `citation()` renders it as
`(sars.gov.za, verified YYYY-MM-DD)`, appended to every formatted result. The
data is a **curated, hand-maintained dataset**, not a live SARS feed — it
reflects filing-season notices and legislation as read and recorded on the
`last_verified` date, not a real-time source. Both tool docstrings say this
explicitly, because "hallucinated dates presented as authoritative" is the
single worst failure mode a tool like this could have.
## Response-size discipline
Added ahead of MCP server #2, so the pair demonstrates a standard rather than
two one-off designs. `_format_deadlines` — the formatter shared by both
tools — caps output at 50 entries. If a result set is ever larger, the
response is truncated with a line stating how many were omitted and how to
narrow the query using the category filter, rather than either silently
truncating or growing unbounded.
At the current dataset size (13 deadlines total) this cap never fires — the
honest state of things is "no query has ever hit this limit." It exists on
principle: Anthropic's tool-writing guidance treats response-size budgeting
as a first-class design concern, not something to retrofit once a dataset
happens to grow large enough to hurt.
## Security posture
- **Read-only, no state mutation, no external network calls.** Both tools
query an in-memory list built once from a local JSON file at startup. There
is no code path in this server that writes anything, calls out to a network
service, or executes a subprocess. This is the smallest possible attack
surface for an MCP server and it's worth stating plainly rather than
leaving it implicit.
- **No user-supplied string reaches a file path, a query, or a shell.** The
only argument that varies per call (`category`) is validated against a
closed five-value enum before it's used for anything. There's no way for a
calling agent's input to be interpreted as anything other than one of those
five literal strings.
- **Tool descriptions contain no dynamic content.** Both docstrings are
static, hand-written text, not assembled from the dataset or any external
source. Since tool descriptions are loaded into the calling agent's
context (OWASP MCP Top 10 — tool poisoning), this closes off the most
direct version of that risk for this server: there's nothing in the
dataset that could get concatenated into a description an agent trusts.
- **A dependency was found unpinned during this session — `dependencies =
["mcp"]`, no version constraint.** A routine `pip install -e .` pulled
`mcp` 2.1.1, which renamed the `FastMCP` API this server is built on
(`MCPServer` in 2.x) and broke the import outright. Fixed by pinning
`mcp<2` in `pyproject.toml`. Included here rather than left in a commit
message: an unpinned dependency in a published MCP server is exactly the
agentic-supply-chain risk OWASP ASI04 names — anyone installing this
server inherits whatever version resolution pip does that day, and this
incident is a concrete example of that going wrong on the very machine
that built it.
- **What this doesn't cover:** no rate limiting beyond what an MCP client
enforces; no automated check that `data/deadlines.json` hasn't been
tampered with between commits (it's a plain tracked file, verified by
normal git history rather than a checksum).
## Known limitations
Stated honestly, not hidden — a passing test suite proves the code does what
it says, nothing about whether the underlying claim is true.
- **Citations are source-supported, not independently verified at
subsection level.** During this dataset's construction, some legal
citations were initially written at a more specific level (exact
subsection references) than the source material actually supported, and
were caught and softened to the general provision the source could
substantiate. Every citation in the dataset reflects that correction, but
it means "verified" here means "the date and provision are supported by
the cited source," not "independently checked against the exact
subsection of the Act." Worth stating precisely rather than letting
"verified" imply more rigor than was actually applied.
- **The dataset requires manual updates.** There's no mechanism that
detects when a SARS deadline changes or a new filing season is announced.
`last_verified` tells you when a human last checked, not that the date is
still current today.
- **Small dataset, so the response-size cap is currently unexercised.** The
50-result truncation is tested via the code path, not via a real query
that actually triggers it — worth flagging rather than implying it's been
proven under load. Live verification via the MCP Inspector CLI confirmed
the cap doesn't fire early against the current 13-entry dataset — it does
not confirm the cap fires correctly when a result set actually exceeds 50,
since nothing in the current dataset does.
- **The custom "teaching" error for an invalid `category` is unreachable
through the live MCP protocol.** Because `category` is typed as a
`Literal`, FastMCP's own schema validation rejects an invalid value with a
Pydantic error before the tool function body — including its custom error
message — ever runs. Confirmed live via the MCP Inspector CLI: calling
either tool with an invalid `category` returns FastMCP's own
`literal_error` message (which does list the valid enum values), not the
app's own "Errors teach, they don't just fail" text described above. That
custom error-handling code still exists and is still covered by unit
tests that call the function directly, bypassing protocol-level schema
validation — but a real MCP client can never actually reach it for this
field. Worth stating plainly rather than letting the tools section imply
the app's own error path is what a live caller sees.
## Development
```bash
pip install -e ".[dev]"
pytest
mypy src
```
Requires Python 3.11+. `mcp<2` is a hard pin — see Security posture above
for why.
## Verification
Protocol wiring verified against the MCP Inspector CLI. Full test suite: 41
tests across the data layer (`test_deadlines.py`) and the MCP tool layer
(`test_server.py`), the latter using an explicit clock seam so
`get_upcoming_deadlines` can be tested against fixed dates rather than
`datetime.now()`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues