v0.16.0
[0.16.0] — 2026-07-28
Closes OBS-001 as far as this repository reaches.
A client-level test of the error paths
The gap was precise: no test distinguished the protocol-error path from the
execution-error path. Every existing test called the tool functions directly,
where isError is not observable at all — the distinction the check is about
was invisible to the entire suite.
tests/test_error_paths.py drives a real ClientSession over an in-memory
transport instead. Nine tests, covering:
- Execution errors — an over-long query and an unknown field both arrive as
a tool result withisError: true, carrying no traceback and no filesystem
path. - The
degradedenvelope — an upstream outage stays a result, with
provenance="degraded"andcount == 0, and is asserted to be
distinguishable from a genuinely empty answer. That is a deliberate deviation
from the check, defended in the test's own docstring: the envelope carries the
source, the retrieval time and a note, where raising would collapse all of it
into one line and lose the difference between "nothing matched" and "I could
not ask". - Protocol errors — a request for a method the server does not implement
raisesMcpErrorrather than returning a result.
Mutation-tested: making the degraded path raise fails 2 tests.
Two things went wrong while writing this and are worth naming. The
degraded-versus-empty test used the same query twice, so the shared client cache
(see SDK-001) served the second call as cached and the test asserted nothing
about degradation at all. And the file cost 28 seconds until the real 2s/4s/8s
retry backoff was stubbed out — the timing is tested in test_resilience.py, and
paying for it again here bought nothing.
Two SDK limits pinned rather than papered over
- Protocol errors carry code 0, not the
-32601the check asks for, even
thoughmcp.typesdefinesMETHOD_NOT_FOUNDand friends. That is above the
tool layer; nothing here can change it. - An unknown tool is reported as
isErrorinside a tool result rather than
as a protocol error, so "no such tool" and "the tool failed" are
indistinguishable to a client without reading the text.
Both are asserted as they are, so an SDK change arrives as a failing test rather
than as a surprise. OBS-001 therefore stays partial — for a reason that is
now written down instead of unknown.
Documentation caught up with the code
ROADMAP.md still listed SEC-004, SEC-005 and ARCH-002 as open work; all
three were closed in 0.13.0 and 0.14.0, within twenty minutes of the table being
written. SECURITY.md still described the ARCH-012 README contradiction as
live, though it was fixed in 0.11.1 with a parametrised guard over both language
files.
SECURITY.de.md was the worse case. Its accepted-risk section had not been
updated since 0.2.0 and still told a German reader that the HTTP transports "run
stateless, so a second instance would not break sessions" — the exact claim the
English file corrects as wrong. Both assessments are rewritten to match, and
the stale release chronicle above them now says so rather than reading as
current. A wrong reassurance in a security document is worse than an open
finding.
mcp constrained below 2.0
mcp 2.0.0 was published and removed mcp.server.fastmcp outright — the API
moved to mcp.server.mcpserver. The dependency was an unbounded >=1.28.1, so
CI resolved to it and every job died on ModuleNotFoundError at import: main
as well as open branches, with nothing in any diff to explain it.
Now >=1.28.1,<2. Verified rather than assumed: the full suite runs green
against 1.29.0 and LATEST_PROTOCOL_VERSION is unchanged at 2025-11-25, so
the bound admits the newest compatible release and excludes only the break.
Migrating to the 2.x API is real work and a decision to take deliberately. A
resolver picking a major version on publication day is not that decision.
CI — the MCP registry publish is idempotent
The PyPI step carries skip-existing: true; the registry step had no
equivalent, so a second trigger for a version already published turned a
completed release into a red build.
Not hypothetical: it happened three times (publish runs #1, #3, #7), always the
same way — a workflow_dispatch publishes successfully, then the tag push for
the same version arrives minutes later and is rejected as a duplicate. This
workflow declares both triggers and both are legitimate, so the collision is
designed in rather than a release mistake.
A duplicate means the desired end state already holds, so it is now treated as
success. Every other failure still fails the job — the point of a red
publish build is that a real failure gets noticed, and it will not be if the
usual outcome is also red. The historical PyPI-404 case (registry looking for a
release that never reached PyPI) still fails, which was verified rather than
assumed: the step's shell was extracted and run against four outcomes — success,
duplicate, 404, and a non-1 exit code.
No package change of its own; it ships with this release.