Skip to content

Releases: malkreide/swiss-procurement-mcp

v0.18.5

Choose a tag to compare

@malkreide malkreide released this 02 Aug 21:37
4d06a1d

[0.18.5] - 2026-08-02

Fixed

  • structlog carried no upper bound, and the index already serves a major past
    the floor.
    The declared range was structlog>=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 how swiss-energy-mcp 0.3.3 became
    uninstallable when mcp 2.0.0 removed the module it imported.

    Now structlog>=24.1,<27. The bound is measured rather than guessed: this package
    installs and imports against structlog 26.1.0 today, so the cap admits what
    demonstrably works and stops only the next, unknown major.

  • starlette carried no upper bound, and the index already serves a major past
    the floor.
    The declared range was starlette>=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 how swiss-energy-mcp 0.3.3 became
    uninstallable when mcp 2.0.0 removed the module it imported.

    Now starlette>=0.37,<2. The bound is measured rather than guessed: this package
    installs and imports against starlette 1.3.1 today, 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.18.4

Choose a tag to compare

@malkreide malkreide released this 02 Aug 13:22
eb192ae

[0.18.4] — 2026-08-02

Fixed

  • Every request to simap.ch announced swiss-procurement-mcp/0.4.0. The
    package on PyPI was 0.18.3; the User-Agent had been fourteen minor versions
    stale for months. Measured at the installed artefact, not in the source tree.

    The cause is worth recording, because it was an earlier repair of this same
    bug. constants.py carried

    # Single source of truth for the version string; `__init__` re-exports it, so
    # the User-Agent cannot drift from the packaged version again (it said 0.3.0
    # while the package was already 0.3.1).
    VERSION = "0.4.0"

    A hand-written constant is not a source of truth for a number the build
    decides — it is a second copy, and second copies drift. The comment promised
    the drift could not happen while sitting on top of the mechanism that caused
    it, and __init__ re-exported the wrong value as __version__, so anything
    introspecting the package got it too.

    VERSION now comes from importlib.metadata, which is written at install
    time by the thing that sets the version and therefore cannot disagree with
    the artefact it describes. The fallback for an uninstalled source tree is
    0.0.0+source — a PEP 440 local segment that cannot be read as a release.

  • Nothing in this repository ever asserted anything about the version.
    tests/test_version_identity.py now checks that VERSION equals the
    installed distribution, that __version__ re-exports it, that the
    User-Agent carries it, and that no version literal returns under src/.

    The scan is semantic rather than shape-based: this package is full of IP
    literals (127.0.0.1, 10.0.0.0/8) that look exactly like version numbers.
    It matches a version-shaped literal assigned to a version-named constant, or
    one riding inside this package's own User-Agent token. Two further tests
    assert that it fires on both shapes that actually shipped and stays quiet on
    the addresses, the protocol date pin and the fallback marker.

    Worth noting for the portfolio: the standard scripts/check_version_sync.py
    would not have caught this. It looks for hardcoded versions inside
    User-Agent-shaped strings (name/version), and here the literal was a bare
    constant feeding an f-string. Run against this repository in its broken state
    it reported Versions-Sync OK.

v0.18.3

Choose a tag to compare

@malkreide malkreide released this 30 Jul 13:33
cbe161c

[0.18.3] — 2026-07-30

Moves the ARCH-011 deviation rationale into README.md, where the criterion
looks for it. Documentation only.

The 2026-07-30 re-audit found the 0.18.2 record was in the wrong file. The check
asks that "Abweichungen vom Standard sind im README begründet"; the argument sat
in SECURITY.md. That is one of the finding's two open criteria — the other is
the missing tools/ package at nine tools, which stays open by decision — so
ARCH-011 remains partial. Half a finding, closed where it was cheap.

SECURITY.md keeps the audit-side facts and links to the README section rather
than repeating the argument. Two copies of the same rationale drift, which this
document already says about parallel chronicles.

A stale tree, found on the way

Both project-structure trees were out of date, and the German one more so: it was
missing inputs.py, _log.py, _fuzzy.py, _net.py and _cors.py, the English
one the last three. A structure section that does not list the modules is a poor
argument for the structure being fine, so both now match the package.

v0.17.0

Choose a tag to compare

@malkreide malkreide released this 29 Jul 09:42
5be2afd

[0.17.0] — 2026-07-29

Migrates to mcp 2.x, which closes the OBS-001 criterion that 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, with a
stated purpose: "when the SDK starts emitting a real code this test fails,
which is the point."
It fails now.

Under 2.0 a protocol error carries a real JSON-RPC code — resources/read on a
missing resource answers -32602 (INVALID_PARAMS), 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
partitioned the server-error range, reserving -32020-32099 for MCP.

Both tests were rewritten from pins into 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 is delivered as a tool result with
is_error: true rather than as a protocol error. And mask_error_details does
not exist in 2.0 either, so OBS-002 stays test-enforced rather than configured.
One thing did improve there — prompts/get used to echo the raw ValueError
("Unknown prompt: nope") and now answers "Internal server error", keeping the
detail server-side.

API changes, and why they are small

The SDK surface this server touches turned out to be two imports:

  • mcp.server.fastmcp.FastMCPmcp.server.mcpserver.MCPServer. Same
    constructor kwargs; @mcp.tool(annotations=…), mcp.run(), sse_app() and
    streamable_http_app() are unchanged.
  • mcp.settings.host / .port / .stateless_http are gone. Host and port now
    go straight from the environment to uvicorn, which is where they were always
    headed — the settings object was a detour. stateless_http became an argument
    of streamable_http_app(), which is better: the mode is a property of the app
    being built rather than global state a later reader has to hunt for.

Tests needed more, all in one file plus three renames: McpErrorMCPError,
create_connected_server_and_client_sessionmcp.Client(server), and
camelCase → snake_case throughout (isErroris_error, inputSchema
input_schema). The MCP_STATELESS tests moved from reading a global to
recording what actually reaches the SDK — a better assertion than the one they
replaced. Mutation-tested: dropping the flag fails 2 tests, routing SSE through
the streamable builder fails 1, dropping the lifespan fails 1.

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. It also reclassifies HTTP+SSE as
Deprecated with a twelve-month removal window.

Nothing breaks today — the SDK still ships the legacy transports, and _cors.py
was re-verified against the starlette 1.3.1 that mcp 2.0 pulls in (preflight
200, session header allowed and exposed, DELETE allowed). But it changes what
SEC-009 and SCALE-002 are about: they move from controls this server has
not implemented toward controls the protocol no longer defines. They stay fail
until the audit catalogue catches up, because reclassifying a finding on our own
authority is exactly the kind of quiet drift these documents exist to prevent.
ROADMAP.md now carries retiring SSE as dated work rather than a someday.

232 tests pass (one more than 0.16.0 — a negative control for the stateless
flag), ruff check and ruff format clean.

v0.16.0

Choose a tag to compare

@malkreide malkreide released this 29 Jul 08:26
6bcec03

[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 with isError: true, carrying no traceback and no filesystem
    path.
  • The degraded envelope — an upstream outage stays a result, with
    provenance="degraded" and count == 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
    raises McpError rather 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 -32601 the check asks for, even
    though mcp.types defines METHOD_NOT_FOUND and friends. That is above the
    tool layer; nothing here can change it.
  • An unknown tool is reported as isError inside 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.

v0.15.0

Choose a tag to compare

@malkreide malkreide released this 28 Jul 11:28
42deecb

[0.15.0] — 2026-07-28

SEC-009 and SCALE-002: addressed as far as the SDK and the absence of an
identity provider allow. Neither flips to pass, and the reasons are now written
down precisely rather than as "accepted risk".

MCP_STATELESS

Opt-in session-free operation for the streamable-http transport. With no session
tracking there is no session id to bind to a user and none to route consistently
to an instance — both problems are removed rather than solved.

Opt-in rather than default because it is not free: a stateless server cannot
resume an interrupted SSE stream or deliver server-initiated notifications, both
of which need a session to belong to. Requesting it on the legacy SSE transport
logs a warning and changes nothing, because leaving the flag set there would tell
an operator they are session-free when they are not.

docs/load-balancing.md

nginx and Kubernetes Ingress configurations keyed on Mcp-Session-Id, with the
buffering and timeout settings the long-lived transports need — plus the honest
part: affinity prevents misrouting, not loss. If the instance holding a
session dies, the session dies with it and a correct client re-initializes.

Two limits found by reading the SDK rather than assuming

  • No explicit session TTL is settable. session_idle_timeout exists on
    StreamableHTTPSessionManager, but FastMCP passes it through neither
    Settings nor its constructor. Same class of limitation as
    mask_error_details, and the reason SCALE-002 still cannot pass.
  • SEC-009 is unreachable, not merely unimplemented. It requires a user id
    from a validated OAuth sub claim. This server has no authentication at all,
    so there is no identity to bind to.

A false claim corrected

The previous SECURITY.md stated the HTTP transports "run stateless, so a second
instance would not break sessions". That was wrong — stateless_http defaults to
False, so streamable-http did keep per-client sessions in memory. The
correction is stated explicitly rather than quietly removed: a wrong reassurance
is worse than an open finding.

v0.3.1 — MCP Registry publish fixes

Choose a tag to compare

@malkreide malkreide released this 27 Jul 13:13
5af8287

[0.3.1] — 2026-07-27

Release-plumbing only — no functional change to the server or its tools.

Fixed

  • MCP Registry publish. The registry rejected server.json with a 422
    because description exceeded the 100-character limit (it was 217). Shortened
    to 97 characters, keeping the searchable domain terms and the scope claim
    (all cantons + federal).
  • PyPI package ownership validation. The registry verifies ownership of a
    PyPI package by looking for an mcp-name: <server-name> marker in the
    published package README. It was missing, so the registry could not attribute
    the package to io.github.malkreide/swiss-procurement-mcp. Added as an HTML
    comment at the top of README.md (the package long_description), which
    keeps it invisible in the rendered README. Because PyPI releases are
    immutable, the marker can only reach PyPI via a new version — hence 0.3.1.

v0.3.0

Choose a tag to compare

@malkreide malkreide released this 27 Jul 12:50
3aa1cc6

[0.3.0] — 2026-07-26

Closes the last two open audit findings from the 0.2.0 hardening pass.

Added

  • search_procurements_detailed — aggregated tool that runs a search and
    fetches the full detail record for the top n hits in parallel
    (asyncio.gather), so the anchor query is answered in a single call instead of a
    search-then-N-details chain (ARCH-007). Bounded by top_n (1–5).
  • EnrichedSearchResponse model (count, total_matched, match_type,
    results: list[ProcurementDetail]).

Changed

  • Attribution now states the data-reuse basis: the tenders are official
    public-procurement announcements and simap.ch publishes no explicit open-data
    licence, so reuse follows the simap.ch terms. Source/operator/terms are named in
    ATTRIBUTION, the README Credits and every response's source (CH-004).
  • Refactored shared search-param building and detail mapping into _build_search_params
    and _detail_from_payload (used by both the single and aggregated tools).