Releases: malkreide/amtsblatt-mcp
Release list
v0.22.1
[0.22.1] - 2026-08-02
Fixed
-
structlogcarried no upper bound, and the index already serves a major past
the floor. The declared range wasstructlog>=24.1; PyPI has been serving
26.1.0. The artefact does not change — the resolver's answer to the next
fresh install does, and that is exactly howswiss-energy-mcp0.3.3 became
uninstallable whenmcp2.0.0 removed the module it imported.Now
structlog>=24.1,<27. The bound is measured rather than guessed: this package
installs and imports againststructlog 26.1.0today, so the cap admits what
demonstrably works and stops only the next, unknown major. -
starlettecarried no upper bound, and the index already serves a major past
the floor. The declared range wasstarlette>=0.37; PyPI has been serving
1.3.1. The artefact does not change — the resolver's answer to the next
fresh install does, and that is exactly howswiss-energy-mcp0.3.3 became
uninstallable whenmcp2.0.0 removed the module it imported.Now
starlette>=0.37,<2. The bound is measured rather than guessed: this package
installs and imports againststarlette 1.3.1today, so the cap admits what
demonstrably works and stops only the next, unknown major.
A dependency range only reaches users through a new release, hence the
version bump. No code changed.
v0.22.0
[0.22.0] — 2026-07-30
Closes ARCH-003 properly. The 2026-07-30 re-audit recorded it as still
partial and found that the reason 0.20.0 declined it was factually wrong.
The correction
0.20.0 declined criterion 1 — no fuzzy or suggestion mechanism — on the grounds
that all three searches query "bankruptcy notices, debt-collection summonses,
estate calls, construction objections", with the failure mode of naming the wrong
company as bankrupt.
Every rubric in that list is red and unreachable through any tool. KK,
SB, SR, LS, NA (Konkurse, Schuldbetreibungen), ES / TE-*
(Erbschaft), GB-* / GE-* (gerichtliche Vorladungen) and BP-* (Baugesuche)
all sit outside GREEN_RUBRICS — an allow-list that exists precisely to exclude
systematic natural-person data. The searchable set is therefore the
non-sensitive one: the set criterion 1 applies to, not the set criterion 4
exempts. The exemption being claimed covered rubrics this server refuses to
serve.
What survives, and what changes
The narrower concern is real: HR / BH (Handelsregister) and OB-*
(Beschaffungen) name legal persons, so silently re-running a search with a
broadened company name would return notices about different companies and
present them as the answer to the original question.
So the server suggests and never executes. On match_type == "none" the response
offers shorter forms of the caller's own keyword — Schulhausneubau →
Schulhaus, Schul — stated explicitly as not queried. The model chooses. No
request is issued for a suggestion, so no notice can be attributed to a term the
caller did not pick, and MatchType still has no fuzzy member because no
response is ever a fuzzy match.
Only prefixes of what the caller typed: no stemmer and no dictionary, which would
invent a term they never used. Multi-word queries offer their longest token first
— "mobile Metallbauten" is asking about Metallbauten. The prefix schedule is
geometric to a four-character floor, reusing the shape the companion server
measured against the live API after a fixed 30%-per-step version stopped three
characters short of the term that works.
A gap the mutation testing found
Four mutations were run; three bit immediately. Removing the length floor in
_add did not — the prefix schedule already floors at MIN_TERM_LENGTH, so that
guard is only reachable via short tokens in a multi-word query. Without it,
Muster AG suggested AG as a search term across the whole gazette, and
every test in the file passed. The assertion now exists and the mutation fails.
Documentation
SECURITY.md and SECURITY.de.md both carried the wrong justification and are
corrected in place rather than quietly rewritten — the sections now state what
was claimed, why it was wrong, and what replaced it. tests/test_empty_results.py
carried it too, in its module docstring.
Mutation-tested four ways: dropping the suggestions from the note fails 1,
removing the "not queried" wording fails 1, flattening the prefix schedule fails
1, removing the length floor fails 1.
v0.18.0
[0.18.0] — 2026-07-29
Moves off SSE. The server now serves streamable-http on /mcp by default;
MCP_TRANSPORT=sse still works, on /sse + /messages, and logs a warning.
Why now, and why not a clean cut
Spec 2026-07-28 reclassifies HTTP+SSE as Deprecated with a twelve-month removal
window and removes protocol-level sessions outright. SSE was this server's only
HTTP transport, which made it the portfolio's most exposed to that clock.
Removing SSE in the same release was the obvious-looking move and the wrong one.
This service is cloud-deployed and the endpoint path changes — every client
config pointing at /sse would have broken on upgrade, silently, with a symptom
(connection refused on an unknown path) that points nowhere useful. A deprecation
window exists precisely so that the transport switch and the client migration do
not have to happen in the same minute. So both transports ship, streamable-http
is the default, and the startup warning names the deadline and the new path.
One builder, two transports
_build_sse_app() became build_http_app(kind). Both transports get the
identical middleware stack — bearer gate, rate limit, CORS, in that order — from
one function, because a control that holds on one transport and not the other is
worse than a missing one: it looks enforced.
tests/test_cors.py is parametrised over both for the same reason and grew from
12 tests to 25. It gained test_the_api_key_is_required_on_every_http_transport,
which is the check that matters when a third transport is added some day: the
loud failure on a missing MCP_API_KEY has to be a property of building any HTTP
app, not something the SSE branch happened to do.
MCP_STATELESS became reachable
SECURITY.md, ROADMAP.md and docs/load-balancing.md all recorded this option
as unavailable here, correctly — SSE has no stateless mode. On streamable-http,
MCP_STATELESS=1 runs the server with no session tracking at all: session
hijacking and session affinity stop being risks to mitigate and become states
that cannot occur.
Neither SEC-009 nor SCALE-002 flips to pass — one asks for binding, the
other for routing, and absence is neither. The exposure each describes is gone
while it is enabled, which is worth more than the score. All three documents are
corrected rather than quietly updated; each says what it used to claim and why
that was true when written.
The flag is ignored on sse, deliberately: leaving it apparently in effect would
tell an operator they run session-free when they do not.
Verification
tests/test_transport.py is new — 11 tests covering endpoint identity per
transport, the stateless wiring, the deprecation warning and the dispatch table.
The warning tests capture structlog's real output through the production
processor chain rather than caplog, which sees nothing here because structlog
writes to its own stderr factory.
Mutation-tested four ways: defaulting to SSE fails 1, dropping the stateless flag
fails 2, removing the deprecation warning fails 1, and skipping the MCP_API_KEY
check fails 2.
248 tests pass (up from 215), ruff check clean. Dockerfile, compose.yaml
and the CI smoke test now set MCP_TRANSPORT=streamable-http.
v0.17.0
[0.17.0] — 2026-07-29
Migrates to mcp 2.x, which closes the OBS-001 criterion 0.16.0 had to
leave open. Protocol version moves from 2025-11-25 to 2026-07-28.
The pinned tests did their job
0.16.0 shipped two tests asserting that protocol errors carry code 0, whose
stated purpose was to fail the day the SDK emitted a real one. They fail now.
Under 2.0, resources/read on a missing resource answers -32602
(INVALID_PARAMS) and prompts/get answers -32603. The spec made the same
correction independently: 2026-07-28 moved resource-not-found from -32002 to
-32602 to align with JSON-RPC and reserved -32020…-32099 for MCP. Both
tests became assertions, plus a range check so a regression to 0 cannot pass
unnoticed. OBS-001 criterion 3 is met.
Unchanged and still pinned: an unknown tool arrives as a tool result with
is_error rather than as a protocol error. mask_error_details does not exist
in 2.0 either, so OBS-002 stays test-enforced. One detail improved —
prompts/get used to echo the raw ValueError and now answers "Internal server
error", keeping the detail server-side.
A gap the migration exposed
Mutation-testing the migrated code found something that predates it: deleting
lifespan=_lifespan from the server construction left all 214 tests passing.
The pooled HTTP client's shutdown hook (SDK-001) had no guard here, though the
sister server has had one since its 0.10.0. test_the_pooled_client_has_a_shutdown_hook
closes that, asserted against the user-supplied lifespan specifically — 2.0
installs a default one, so the weaker "some lifespan is set" check would have
passed with ours removed.
API changes
Two imports: FastMCP → MCPServer, same constructor kwargs; the tool
decorator, run() and sse_app() are unchanged. mcp.settings.host / .port
are gone, so bind_host() / bind_port() read the environment and hand the
values to uvicorn directly — which is where they were always going.
Tests needed McpError → MCPError,
create_connected_server_and_client_session → mcp.Client(server), and
camelCase → snake_case (isError → is_error).
What the new spec means for the accepted risks
2026-07-28 removes protocol-level sessions — no initialize handshake, no
Mcp-Session-Id, no SSE stream resumability — and reclassifies HTTP+SSE as
Deprecated with a twelve-month removal window.
That lands harder here than on the sister server, because SSE is this server's
only HTTP transport. Nothing breaks today: the SDK still ships sse_app(), and
_cors.py was re-verified against the starlette 1.3.1 that mcp 2.0 pulls in
(preflight 200, Mcp-Session-Id allowed and exposed, bearer gate intact).
SEC-009, SCALE-002 and SCALE-003 change character — from controls this
server has not implemented toward controls the protocol no longer defines — but
stay recorded as fail until the audit catalogue catches up. Reclassifying a
finding on our own authority is the drift these documents exist to prevent.
ROADMAP.md now carries migrating off SSE as dated work.
215 tests pass, ruff check clean.
v0.16.0
[0.16.0] — 2026-07-28
Closes OBS-001 as far as this repository reaches, and fixes a real gap found
while testing for it.
A client-level test of the error paths
Every existing test awaited the tool functions directly. That is right for tool
logic and useless for this check: it cannot observe isError, cannot observe a
JSON-RPC error code, and cannot tell the two apart. tests/test_error_paths.py
drives a real ClientSession over an in-memory transport instead — 11 tests
covering argument errors, refusals, upstream outages and protocol errors.
Refusals and outages now carry provenance
Found by writing those tests: every tool returns str (the accepted SDK-002
deviation), so a client has no typed field to read — and the failure paths
returned a bare German sentence with no footer, while every successful answer
ended in _provenance: live_api_. Telling "the source is down" from "nothing
matched" meant parsing prose.
All three outcomes now wear the same envelope:
live_api— the source answered.refused— this server declined by design (blocked rubric, invalid code,
egress denial). Retrying changes nothing.degraded— the source could not be reached or returned an error. The same
call may work later.
The attribution comes along, which the licence wanted on every response anyway
and which the failure paths had been quietly omitting.
Mutation-tested three ways: dropping the footer fails 4 tests, mislabelling a
refusal as degraded fails 1, and raising instead of degrading fails 3.
Two SDK limits pinned rather than papered over
- Protocol errors carry code 0, not the
-32601the check asks for, even
thoughmcp.typesdefines the constant. Above the tool layer; not fixable
here. - 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 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 OPS-001, SEC-004, SEC-005, OBS-006 and
ARCH-002 as open work; all five were closed in 0.12.0–0.14.0, within an hour
of the table being written. The rows are removed and the closures named, with
the reason the audit under audits/ still disagrees: it is a measurement taken
at a point in time, not a status board.
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.
v0.15.0
[0.15.0] — 2026-07-28
SEC-009 and SCALE-002/003: documented precisely rather than carried as a
bare "accepted risk". Neither flips to pass, and neither is a code change
waiting to be written.
docs/load-balancing.md adds nginx and Kubernetes Ingress configurations keyed
on Mcp-Session-Id, with the buffering and timeout settings the long-lived SSE
transport needs, and the honest failover statement: affinity prevents
misrouting, not loss.
SECURITY.md gains criterion-by-criterion sections for both. Two limits, found
by reading the SDK rather than assuming:
- No explicit session TTL is settable —
session_idle_timeoutexists on
StreamableHTTPSessionManagerbut FastMCP exposes it nowhere. SEC-009is unreachable, not unimplemented — it needs a user id from a
validated OAuthsubclaim, and a shared bearer key carries no identity.
The sister server's MCP_STATELESS escape hatch is not available here: this
server serves the legacy SSE transport, which has no stateless mode. Gaining it
means migrating to streamable-http — a deliberate change to a cloud-deployed
service, not a remediation step.
Also fixes a self-contradiction in SECURITY.md, which listed OPS-003 as
closed in 0.10.0 and still open four lines later.
Release v0.1.2
Merge pull request #10 from malkreide/claude/align-repo-documentation…
v0.1.1
v0.1.0
Changelog
All notable changes to this project are documented here. The format follows
Keep a Changelog, and this project
adheres to Semantic Versioning.
[0.1.0] — 2026-07-20
Initial release. Implements the amtsblatt-mcp specification split out of
register-mcp (docs/amtsblatt-mcp-proposal.md).
Added
- Fail-closed green allow-list (
src/amtsblatt_mcp/rubrics.py) — 49 released
rubrics plus 4 sub-rubrics out of the 152 live top-level rubrics. All 152 are
explicitly classified; anything unclassified is blocked by default. - Five read-only tools:
search_publications,search_procurement,
get_publication,list_rubrics,source_status. - Two-layer green gate: checked at the tool boundary and again in the query
builder, so no code path can smuggle a blocked rubric into a request. - Explanatory refusals for blocked rubrics — with the reason, without a
circumvention hint, and without an HTTP call. - Europe/Zurich deadline arithmetic for procurement submission dates.
- Language deduplication for notices published in de/fr/it.
- Defensive XML parsing across per-sub-rubric schemas, including unescaping and
stripping entity-encoded HTML bodies used by the procurement rubrics. - Guards for the verified upstream quirks: Silent Ignore (parameter allow-list
plus corpus-size plausibility check) and Silent Empty (taxonomy validation
before every call). - Egress allow-list on the httpx client, enforced on redirects too.
- Bearer auth and sliding-window rate limiting for the SSE transport.
- Structured JSON logging; optional OpenTelemetry via the
otelextra.
Notes on deviations from the specification
- The proposal's traffic-light table uses glob notation (
KA-*,RS-*, …).
Globs are expanded to literal codes: a glob in code would auto-green any
future upstream rubric matching the prefix, violating the proposal's own
fail-closed rule. - Three documented extensions to the green set after review:
KO-*(communal
notices, the twin of the greenKA-*),PL-BL(Basel-Landschaft spells
Politische RechtePL-, notPR-), andVE-*(environment/transport/
energy, institutional). - Explicit red entries added for rubrics the source table did not cover:
AA-GR,BU-*,GR-BL,GR-BS,SJ-BE. - The upstream
uidsparameter is not exposed. The UID-keyed join belongs
toregister-mcp; a second entry point would sit outside this server's scope.
See docs/rubric-classification.md for the
full audit trail.