Skip to content

v0.4.0

Choose a tag to compare

@malkreide malkreide released this 31 Jul 21:00
c1affbf

Changed

  • Migrated to the mcp 2.x server API. Pin >=1.28.1,<2>=2.0.0,<3;
    FastMCPMCPServer (mcp.server.mcpserver). The floor is hard: 2.0.0
    removed mcp.server.fastmcp with no compatibility shim, so this code cannot
    run on 1.x, and a >=1.x range would let a resolver pick a version that
    fails at import.

  • The session idle timeout (SEC-009) had to be reinstalled differently, or it
    would have vanished silently.
    1.x built the Streamable-HTTP session manager
    lazily — streamable_http_app() only constructed one if self._session_manager is None — so pre-populating that attribute was enough.
    2.x builds one unconditionally, overwrites the attribute, hands it to the
    route's ASGI app and closes the app lifespan over the same local object. The
    1.x approach is a plain no-op under 2.x: no error, just unbounded session
    growth again.

    The manager is now swapped in after the app exists, in both places the SDK
    wired its own into — the route's StreamableHTTPASGIApp.session_manager and
    the app lifespan. Missing either one is silent in a different way: re-pointing
    only the route leaves requests served by our manager while the lifespan starts
    the SDK's, so the reaper never runs on the live sessions. A test enters the
    real lifespan and asserts _has_started on the serving manager, which is
    the only assertion that separates the two cases. Both halves are
    mutation-tested.

  • _SwisstopoMCP.call_tool() would have become a silent no-op (OBS-001).
    The override mapped the envelope's is_error onto the protocol flag by
    matching 1.x's (content, structured) tuple return. 2.x returns a
    CallToolResult, which never matches — so every handled error would have
    reported is_error: false again, and no test on the payload field would have
    noticed. (The signature would also have broken outright: 2.x calls
    call_tool(name, arguments, context).)

    The flag is now flipped on a copy of the already-validated result. That is
    strictly better than the 1.x approach, which rebuilt the result and thereby
    bypassed the SDK's output-schema validation on the error path.

  • Resource errors are sanitised by the SDK now — a client-visible change.
    2.x raises ResourceError(f"Error reading resource {uri}") from exc, with the
    comment "we should not leak the exception to the client". So on a geodienste
    outage the catalogue resource still errors rather than publishing count: 0
    (which is the point of that guard), but the actionable hint the server put in
    the message no longer reaches the client — it survives only server-side on
    __cause__. The test asserts both halves.

  • Protocol drift guard split across the two eras (ARCH-012). 2.x serves a
    legacy initialize handshake capping at 2025-11-25 and a modern
    per-request-envelope era reaching 2026-07-28, over the same server.
    LATEST_PROTOCOL_VERSION aliases the modern one, so the old single assertion
    compared the documented value against the wrong constant and failed. Both
    eras are pinned separately now, and the handshake ceiling is measured against
    a live server: a legacy client asking for 2026-07-28 gets 2025-11-25 back,
    and older clients keep their own revision.

  • transport_security is a per-app kwarg, no longer a constructor argument
    or a readable setting; streamable_http_app() also receives the real bind
    host, since 2.x would otherwise auto-enable a loopback-only allow-list and
    reject every request to a non-loopback bind with HTTP 421.

    Verified against all four CI gates: 866 passed / 38 deselected, 0 failed;
    ruff check src/ tests/ clean; mypy src/ clean; and
    scripts/snapshot_tool_hashes.py --check reports tool-hashes.json is up to date (20 tools) — no tool contract moved. Run in a venv built the way CI
    builds it (pip install -e ".[dev]").

Changed — BREAKING

  • The five api3 tools are now one tool with an operation argument (audit
    ARCH-006).
    swisstopo_search_layers, swisstopo_identify_features,
    swisstopo_find_features, swisstopo_get_feature and swisstopo_layer_info
    are removed and replaced by swisstopo_map_query. 24 → 20 tools. Every
    client calling any of the five must be updated; there is no alias shim.

    Removed tool Replacement
    swisstopo_search_layers swisstopo_map_query(operation="search_layers", query=…)
    swisstopo_layer_info swisstopo_map_query(operation="layer_info", layer=…)
    swisstopo_identify_features swisstopo_map_query(operation="features_at_point", layers=…, lat=…, lon=…)
    swisstopo_find_features swisstopo_map_query(operation="features_by_attribute", layer=…, search_field=…, search_text=…)
    swisstopo_get_feature swisstopo_map_query(operation="feature_by_id", layer=…, feature_id=…)

    Argument names are unchanged within each operation, so a migration is adding
    operation= and changing the tool name. Two behavioural notes:

    • A field belonging to another operation is now an error. With five tools
      the schema made a wrong pairing impossible; with one tool it does not, so
      validation does. The message names the fields the chosen operation accepts.
    • sr no longer exists on the point query. It had been deprecated to
      4326-only there; passing it to operation='features_at_point' is now
      rejected as belonging to another operation. It remains on feature_by_id,
      where it selects the output geometry's coordinate system.

    Two things the merge deliberately does not cost. Operations are named for
    the question rather than the upstream route — features_at_point and
    features_by_attribute, not ESRI's identify and find — so the choice can
    be made from the operation list alone. And each operation keeps its own log
    and trace label (swisstopo_map_query:features_at_point), so per-operation
    timing and error rates survive.

    This reverses the position recorded in 0.3.x, where both READMEs argued the
    five should stay separate and named a measurement as the trigger for
    revisiting. The rationale is rewritten rather than deleted — see "Tool budget
    and aggregation" in either README.

    Resolves the naming ambiguity the previous rationale had deferred to "the next
    breaking release": swisstopo_search_layers and
    swisstopo_list_available_layers both said "layers" while fronting different
    catalogues. The national one is now inside swisstopo_map_query.

Security

  • DNS pinning is on by default (audit SEC-004 / SEC-005).
    SWISSTOPO_PIN_DNS now defaults to 1. Every release up to 0.3.x shipped it
    off, which left the TOCTOU window between the SSRF guard's lookup and httpx's
    own connect-time lookup open in the configuration almost everyone runs —
    including stdio, which has no network-layer compensation. Set
    SWISSTOPO_PIN_DNS=0 to restore the previous behaviour.

    Pinning remains automatically inert behind a forward proxy (the proxy owns
    resolution), so cluster deployments using deploy/egress-proxy.yaml are
    unaffected in either direction.

Fixed

  • DNS pinning used only the first resolved address (audit SEC-005).
    PinnedTransport took addresses[0] and committed the request to it, so an
    AAAA-first answer in an IPv4-only network failed outright — while unpinned
    httpx would have moved on to the next address by itself. This was the concrete
    form of the "a default-on control that breaks egress" objection that kept
    pinning off, so fixing it was the precondition for the default above.

    The transport now walks the resolved list, falling through only on
    ConnectError / ConnectTimeout — a ReadError means the request reached the
    peer and must not be replayed — and only when the request body is buffered, so
    a streaming body is never sent twice. The SSRF guard still runs before the
    first connection and rejects the whole answer if any address is private, so
    the walk cannot become a way around it. On exhaustion the error names the
    hostname rather than the last IP tried.

  • The unit suite depended on HTTPS_PROXY being set. Fallout from the
    default above, caught by CI. PinnedTransport rewrites request.url to the
    resolved address before delegating to httpx.AsyncHTTPTransport — the exact
    layer respx patches — so with pinning on, a route registered for
    https://api3.geo.admin.ch/… never matches what arrives. It also made every
    mocked test perform a real getaddrinfo. Neither showed up in development,
    because a sandbox with HTTPS_PROXY set disables pinning.

    tests/conftest.py now turns pinning off for the suite, deliberately and
    visibly, rather than by accident of environment. Since that would otherwise
    leave the shipped default untested — which is how this reached CI —
    TestTheShippedDefaultDoesNotBreakOrdinaryRequests opts back in and drives a
    request through _build_client() and request_with_retry with pinning
    enabled, asserting the response comes back, the connection went to the pinned
    address, and the allow-list still sees the hostname rather than the IP.

  • The egress-proxy sidecar had no test guarding what it mounts (audit
    SEC-021).
    The manifest's --config-file=/etc/smokescreen/config.yaml flag,
    pointing at a file the documented ConfigMap does not build, was dropped
    earlier — but a deletion leaves no trace, and nothing stopped an equivalent
    flag from being added back. tests/test_deploy_manifests.py now asserts that
    every path the sidecar args name under the mount point is one the documented
    --from-file= command actually supplies.

  • Two paths accepted a Context and dropped it (audit SDK-003). Both fed
    swisstopo_find_commune, one of the two tools that finding named as the
    actually-slow ones. _find_by_name — which backs the name= mode, the
    most-used of the tool's four — took a ctx and never forwarded it to
    openplz_request, so a lookup by name stayed silent through 2+4+8 s of retry
    backoff while the canton= mode of the same tool reported per page.
    _get_canton_index issues one uncached upstream request on the canton path,
    before the paged fetch emits anything, and took no ctx at all.

    Both were missed because the earlier remediation was verified by reading the
    tool signatures, and both tools did have a ctx there. A parameter that is
    accepted and dropped is indistinguishable from one that is used, from the
    outside.

    tests/test_context.py now sweeps the source with two AST guards — no
    function may accept a ctx it never references, and no upstream call with a
    ctx in scope may omit it — plus an assertion that the sweep covers a
    non-trivial set, since a sweep over nothing passes for the wrong reason. All
    three were confirmed to fail against the defect before the fix.

  • The catalogue resource published an upstream outage as an empty catalogue.
    Raised in review of PR #41 by an automated reviewer, and correct. The tool
    that swisstopo://catalogue/layers wraps degrades gracefully — on a
    geodienste failure it returns an envelope with is_error: true and no
    results, which is right for a tool call. Serialising that envelope's results
    into the resource dropped both the flag and the summary, publishing
    count: 0 and an empty list as ordinary JSON, so a client could not
    distinguish an outage from a genuinely empty catalogue.

    The resource now raises, which the SDK surfaces as a ResourceError carrying
    the masked summary. A resource is a document; when there is no document, an
    error is the honest answer rather than an empty one.

    Same defect class as ARCH-003 (a bare negative read as a factual answer) and
    OBS-001 (an error presenting as success) — on a surface added in the very
    commit that closed them.

  • Restored the changelog header. The merge a9f6bc4 resolved a conflict by
    dropping 70 lines, including the document title, the Keep-a-Changelog
    preamble, the ## [Unreleased] heading and the User-Agent entry below. Both
    merge parents had them; this is a merge-resolution accident, not an edit.

Fixed (User-Agent version drift)

  • User-Agent no longer reports a version that was never current. Three
    numbers had drifted apart: pyproject.toml said 0.3.0,
    __init__.__version__ said 0.2.0, and the hard-coded USER_AGENT in
    api_client.py said 0.1 — a value that has not matched a release since the
    very first one. Every request to geo.admin.ch, REFRAME, STAC, Overpass and
    OpenPLZ carried it. __version__ now comes from the installed distribution
    metadata (importlib.metadata, generated from pyproject.toml) and the
    User-Agent is derived from it, so no literal has to be remembered. Running
    from a bare source checkout yields 0.0.0+source rather than a
    plausible-looking wrong number. Guarded by tests/test_version.py.

Changed (ARCH-007)

  • swisstopo_query_geodata fans out over collections concurrently. The loop
    was strictly sequential — the check's named anti-pattern for an aggregation
    tool, and asyncio.gather appeared nowhere in src/.

    The naive fix would have been a regression. The sequential loop stopped as
    soon as it had limit records, often after one request, and a gather over
    every collection throws that away: a single geodienste dataset can hold 24
    collections (measured against av_0), so all-at-once means 24 requests
    against a cantonal service on every call — to save latency only when the early
    ones come back empty.

    It now runs in waves of 4, keeping the early exit while cutting the worst
    case by the wave size, with a cap of 12 collections per call. When the cap
    bites the response says so; a cap nobody is told about reads as "this is
    everything".

    Six tests hold all three properties, since a fix satisfying only the first
    would be worse than the defect: requests overlap (verified to fail at
    concurrency 1), concurrency stays bounded, the early exit survives, results
    stay deterministic under concurrency, and the truncation note appears exactly
    when the cap bit.

Added (OPS-003)

  • docs/isds-dsg.md — the Phase-1 exit gate the check requires. An ISDS
    classification and a DSG assessment existed nowhere and were not documented as
    waived. Both READMEs' phase tables now carry the rows.

    It is a real assessment, and it says two things the easy version would not:

    • Query inputs can be personal data. An address is personal data once
      linkable to a person. "Public open data, therefore no personal data" is
      correct about the responses and wrong about the inputs.
    • Where inputs actually go, checked rather than assumed. The normal log
      carries tool name, correlation id, duration and length — no arguments. But
      handled_error logs str(e), and a validation message can quote the input;
      overpass_error_page logs up to 1000 characters of an upstream body that
      can echo the submitted query. So stderr must be treated as a log that
      occasionally contains request content.

    No processing record is maintained, with three reasons — and an explicit note
    that this does not discharge an operator, who must record this server as a
    processing step in their own application's register. §6 lists what overturns
    the conclusion; §7 states it is an engineering assessment, not a legal opinion.

Fixed (OPS-003)

  • Phase consistency is now mechanical. The original defect (READMEs saying
    Phase 1 while the roadmap said 2.5) and its half-applied remediation (table in
    the English README only, stray "Phase-1 wrapper" sentences, two documents
    naming each other as authoritative) were both found by reading. Nine tests
    hold it instead: the roadmap must declare itself authoritative and no document
    may claim authority back, every phase document must name the current phase,
    none may still describe the server as Phase 1, both READMEs must carry the
    status table with advance criteria and the ISDS/DSG rows, and the assessment
    must contain its own overturn conditions — a waiver without trigger conditions
    is a hand-wave. Verified: dropping the ISDS row from README.de.md alone
    fails the bilingual guard.

Added (OPS-001)

  • Live tests for every tool. Ten tools had none, so the nightly run could
    not detect upstream contract drift for them — including all three ÖREB tools,
    the only cantonal, per-canton-format upstream here and the most drift-prone
    thing this server talks to. Live-marked tests went 25 → 33.

    tests/test_live_coverage.py maps tools to live tests and fails on an
    uncovered one, so the gap cannot reopen. One exemption, with its reason
    (swisstopo_map_url builds a URL locally, there is no upstream to drift), and
    the exemption list is asserted non-stale.

    Three of the new tests are chained rather than pinnedget_feature
    resolves its id via find_features, get_collection via search_geodata,
    get_oereb_extract via get_egrid. A hardcoded id rots, and a rotted id
    produces a nightly false alarm, which is how a drift detector gets muted.

Changed (OPS-001)

  • The failure-reporting step no longer depends on a third-party action. It
    only runs if: failure(), so nothing on that path is exercised by a green
    run — a bad action pin would have failed silently for exactly the reader who
    needed the report. It now uses gh, which ships on the runner and needs no
    pin, keeping the same deduplication, and the job declares issues: write
    explicitly instead of relying on the repository's default token scope. Five
    tests hold those properties, including bash -n on the actual script.

    Verification note: the four non-ÖREB live tests were run against real
    upstreams and pass. The three ÖREB ones could not be verified from this
    environment — oereb.geo.zh.ch is unreachable here while api3.geo.admin.ch
    is not — so they will first execute on the nightly runner.

Fixed (SCALE-002)

  • A test now covers session affinity, which nothing did. No unit test can
    exercise HAProxy, but it can pin the property that creates the requirement:
    two independent session managers over the same server — which is what two
    replicas are — show that a session id resolves on the replica that minted it
    (200) and nowhere else (404). If that ever changes, the single-replica
    default and the whole affinity arrangement are obsolete, and this test failing
    is how anyone finds out. A second test fails if deploy/kubernetes.yaml ever
    raises replicas above 1 — the path the audit says a reader would take and
    get intermittently broken sessions from.

  • sessionAffinity: ClientIP added to the base Service as the crude
    fallback the audit asked for — with its failure mode named where it will be
    read: it is inert at one replica, helps clients reaching the Service directly
    from inside the cluster, and does nothing useful behind an ingress, where
    kube-proxy sees the ingress pod as the source and every client collapses onto
    one backend. A fallback presented without that caveat gets mistaken for the
    solution.

  • A defect in the SCALE-003 fix, caught before it shipped. The HAProxy
    Deployment added in that commit ran replicas: 2, and each HAProxy process
    holds its own stick-table — so two instances behind a round-robin Service
    learn different halves of the session map. A client whose initialize lands
    on one and whose next request lands on the other misses the table entirely:
    the same defect, one layer up. Now replicas: 1, with the peers
    requirement for scaling named in the manifest and the docs, and two tests
    holding both facts. No peers config is shipped — that would be a second
    untested config, which is what the audit found wrong the first time.

Fixed (SCALE-003 / SCALE-002)

  • The HAProxy affinity config parsed, looked correct, and would not have
    worked.
    Two independent defects:

    • stick on <pattern> is shorthand for stick match + stick store-**request**. The MCP session id is minted by the server and
      returned in the response to initialize — that request carries no
      Mcp-Session-Id, so nothing was ever stored. The first request that did
      carry the header missed the empty table, was round-robined, and was then
      pinned to a possibly-wrong replica for an hour. Replaced with
      stick store-response + stick match, the canonical pattern for a
      server-generated identifier.
    • The server lines named swisstopo-mcp-1 and swisstopo-mcp-2, hosts
      nothing in this repository creates, with no resolvers section — so HAProxy
      would have refused to start.
  • The multi-replica path now exists rather than being implied.
    deploy/statefulset.yaml (StatefulSet + headless Service, for stable per-pod
    DNS) and deploy/haproxy-deployment.yaml (HAProxy with the config, exposing
    swisstopo-mcp-lb). haproxy.cfg resolves the headless Service via
    server-template + resolvers, so pods that do not exist yet are DOWN rather
    than a startup failure.

  • The duplicated snippet is removed, not corrected. The same defective
    config sat in deploy/ingress-sticky-sessions.yaml as "Option A (preferred)"
    and was pointed at from the docs — which is how one mistake came to exist in
    three files. That manifest is now scoped honestly to browser clients, since
    MCP hosts do not persist cookies.

  • tests/test_deploy_manifests.py holds the properties that were wrong, all of
    them cross-file — which is why nothing caught them: the backends must
    reference the headless Service this repo defines (read from the manifest,
    not hardcoded), the StatefulSet's security contexts must equal the
    Deployment's, and the mounted ConfigMap must be the one the documented command
    builds — the exact defect SEC-021 had. Verified against the original config.

  • These tests cannot prove HAProxy routes correctly; that needs a cluster.
    docs/deployment.md gained a "Verifying affinity" procedure instead, plus an
    explicit note that failover is a deliberate non-goal — affinity routes
    sessions, it does not replicate them, so a dead pod takes its sessions with it.

Fixed (SEC-014)

  • The read-only premise was enforced one indirection away from itself. The
    gate read t.annotations.readOnlyHint — a value each tool asserts about
    itself — so a future tool that performs a write while still declaring
    readOnlyHint=True would have passed. SEC-014's entire risk-bounding
    argument rests on that premise, and the re-audit had to verify it by hand.

    A static sweep now parses every module in src/, finds each outbound HTTP
    call, and asserts: no PUT/PATCH/DELETE anywhere; every non-GET is a
    named exception with its reason (the only one is Overpass, which puts its
    query in the request body); no method assembled at runtime, since a computed
    verb would slip past a static check; and listed exceptions still occur
    an allow-list that outlives its entries drifts into permission. It asserts it
    found at least 10 call sites, so it cannot pass vacuously.

    Verified both ways: a request_with_retry("DELETE", …) planted in stac.py
    failed two assertions by file and line, and flipping the Overpass POST to
    GET failed the stale-exception test.

    The annotation test stays. The annotation is what a tool says; the method
    sweep is what it does.

    Unchanged and still deferred: the check's actual criteria — a default-deny
    allow-list per role, server-side group scoping, denied-call audit events — are
    impossible without an auth model and belong to a gateway. Both security
    policies previously stated this gap outright; that text was honest and is now
    out of date, so it has been rewritten.

Fixed (SEC-015)

  • The tool-poisoning self-scan read a fraction of what the server ships. It
    checked tool.name and tool.description. Every description inside an
    input or output schema reaches the model's context window identically, and so
    does the 36-line server instructions block — so an injection placed in a
    Field(description=...) passed every assertion. That is worse than no scan,
    because SECURITY.md described it as covering "this server's own
    descriptions".

    The sweep now walks all of it recursively, and three tests pin the surface so
    narrowing it fails the build. Verified: a <SYSTEM>Ignoriere alle vorherigen Anweisungen.</SYSTEM> payload in a schema field now trips two assertions;
    under the previous scan it tripped none.

  • Four missing checks added: role/system markers (<SYSTEM>, [INST],
    ### Instructions:, <|im_start|>, line-initial Human:/Assistant:), a
    length ceiling (only a floor of 40 existed), NFKC canonicalisation on tool
    names alongside isascii(), and confusable Cyrillic/Greek detection for
    descriptions — where isascii() cannot be used, since umlauts are legitimate.

  • Every matcher now has a test that proves it fires. All the scan's
    assertions pass today, so none of them demonstrated the patterns work. There
    is also a negative test asserting legitimate German ("Höhenprofil für Zürich,
    Bauzone gemäss ARE") is not flagged — a check that cries wolf on the language
    it was written for gets disabled, which is a slower way to have no check.

  • Both security policies rewritten to describe the surface the scan actually
    covers, rather than claiming more than it delivered.

    A note on how this went: writing the new scanner I used literal invisible
    characters, exactly the defect the old file avoided — and the guard added in
    the same commit caught eleven of them before it was committed. The guard is
    retained for that reason.

Fixed (SDK-003)

  • Long-running tools were silent. Four gaps, all of which the previous
    remediation plan listed and none of which had been applied:

    • The two slowest tools took no Context at all.
      swisstopo_query_osm_features (25 s server timeout behind a 30 s client
      timeout) now announces the wait before it starts, and reports geocoding an
      area name separately. swisstopo_find_commune threads ctx into
      _fetch_all_pages, which reports per page — that loop can issue up to
      40 sequential upstream requests, so it has a natural cadence.
    • Progress fired after the wait. elevation_profile sent
      progress=1, total=1 once the upstream call had already returned — a
      completion marker, not a cadence. It now reports before the call and
      confirms after.
    • A swallowed legend failure. layer_info caught every exception and set
      legend = None, so a caller could not tell "this layer has no legend" from
      "the legend fetch broke". A legend_status field now distinguishes ok /
      empty / unavailable, the failure is logged, and ctx.warning() fires
      when a context is available.
    • Retries were silent, and that is the amplifier. No ctx reached
      api_client, so even the context-aware tools said nothing during 2+4+8 s of
      backoff — the largest source of unexplained latency in this server. From the
      client's side that is indistinguishable from a hang, and the usual response
      to a hang is to cancel and retry, multiplying load on an upstream that is
      already struggling. request_with_retry now warns before each retry, and
      every per-source helper threads the context, so this covers every tool that
      passes one rather than only the ones edited by hand.

    All reporting is best-effort: a context whose session has gone away raises,
    and a test asserts that does not turn a recoverable blip into a failed call.

    The old test was the reason none of this was caught — it asserted only
    that ctx.info and ctx.report_progress were awaited at all, so it passed
    throughout. Nine tests now assert ordering relative to the upstream call, one
    progress event per page, one warning per retry, and the legend distinction.

Added (SEC-009)

  • SWISSTOPO_SESSION_IDLE_TIMEOUT, default 1800 s. The MCP SDK defaults to
    no session timeout, so every Streamable-HTTP client that disconnects without
    sending DELETE /mcp — a crash, a closed laptop, a killed container — leaked
    a session for the lifetime of the process. Not a confidentiality problem here
    (all 24 tools are stateless reads over public data, so a stolen session id
    confers no privilege), but unbounded growth is still unbounded.

    FastMCP exposes no setting for it and builds the session manager lazily, so
    _install_session_manager() pre-populates it. Verified against a running
    server both ways: an idle session is reaped and returns 404, while activity
    pushes the deadline back. A test also asserts the hand-built manager still
    carries the transport-security settings, since dropping them would silently
    disable DNS-rebinding protection.

    Documented in .env.example, deploy/kubernetes.yaml and both security
    policies. 0 restores the SDK's unbounded behaviour.

Documentation (SEC-009)

  • Server-side session invalidation was already present; only the
    documentation was missing.
    The audit reported no invalidation endpoint,
    which is true of custom routes but misses the protocol's own mechanism:
    DELETE /mcp with the session id terminates it, and the SDK implements it.
    Measured — DELETE returns 200 and the next request on that id returns
    404. Both security policies now say so.

Added (ARCH-003)

  • swisstopo_geocode now relaxes a failed query instead of reporting a bare
    negative.
    match_type: "fuzzy" was a member of the Literal that no code
    produced. On zero results the tool drops the trailing token and retries once,
    reporting hits as fuzzy with a note naming both the original and the
    relaxed query — silently answering a different question would be worse than
    answering none. Two failure modes this recovers: a house number absent from
    the register, and a street spelled differently from the official entry where
    the municipality alone resolves. A single-token query is not retried, since
    that repeats the same search.

Fixed (ARCH-003)

  • Empty results always carry a next step. The note field reached 5 of ~25
    sites that can report match_type: "none", and nothing enforced it — so the
    coverage could regress silently, and did not grow between two audit runs. All
    ~25 sites now supply their own note, held there by two layers:

    • a model_validator fills a fallback whenever match_type == "none" and no
      note was given, so a bare negative is impossible by construction. Filling
      rather than raising is deliberate — turning a missing hint into an exception
      would replace a mildly unhelpful answer with a masked internal error;
    • an AST sweep asserts no call site relies on that fallback, since a generic
      hint is not a next step. It asserts it found ≥20 sites first, so it cannot
      pass vacuously.

    The sharpest case is the ÖREB cluster: only ZH is enabled by default, so an
    empty answer is the normal answer almost everywhere in Switzerland, and a bare
    negative there reads as "no restrictions exist" — a materially wrong statement
    about a legally binding cadastre. It now names swisstopo_municipality_at.

  • The OpenPLZ hints now populate the structured field, not only the summary
    markdown
    , so one contract holds across modules.

  • 18 tests added (tests/test_empty_results.py), two verified to fail against
    the previous implementation. The invariant test was item 4 of the previous
    remediation plan and was never delivered, which is why the coverage could not
    grow.

Fixed (OBS-006)

  • Tracing exported tool arguments via the httpx child spans. The tool span
    this server writes never carried arguments — but the httpx auto-instrumentation
    it enables exported http.url complete with the query string, so every
    argument that becomes a parameter (search text, coordinates, canton, PLZ, the
    Overpass area) reached the observability backend verbatim. True of the span we
    write, false of the system we configure. It only bites when tracing is on,
    which is precisely the cloud deployment.

    A hook now rewrites every URL-bearing span attribute through _scrub_url,
    dropping the query string, fragment and userinfo. Scheme, host and path
    survive, so the span still answers which upstream was called and how long it
    took.

    It is the request hook, and that was measured rather than assumed: the
    response hook never fires when no response arrives, so a connection error
    would have exported its query string intact.

    hook success connection error
    response only scrubbed leaked
    request only scrubbed scrubbed

    The test gap was the substantive half.
    test_arguments_never_reach_span_attributes passed throughout the leak
    because it never enabled the instrumentation. Eight tests added that do,
    including the error path and one asserting the upstream is still identifiable
    — scrubbing that made spans useless would be a different failure. Each asserts
    a span was recorded first, so none can pass vacuously.

    Residual, stated deliberately: the path is kept and can still carry a
    caller-supplied identifier (collection_id, a feature id). This narrows the
    exposure rather than eliminating it.

Fixed (OBS-001)

  • The protocol isError flag was never set. Handled execution errors are
    returned as a ToolResponse with is_error: true rather than raised — which
    is what keeps them out of the JSON-RPC error channel — but the SDK builds a
    CallToolResult with isError=False for any tool that returns normally. The
    payload field was therefore the only signal, and a spec-conformant client
    reading CallToolResult.isError saw success for every handled error: retry
    logic, error dashboards and orchestrating agents would pass a German error
    string downstream as though it were geodata. Reproduced over a real stdio
    session before changing anything.

    _SwisstopoMCP subclasses FastMCP and returns a CallToolResult with the
    flag set when the envelope says so. The seam is supported rather than a
    monkeypatch — the lowlevel handler passes a CallToolResult through
    unchanged. Success behaviour, structured content, source and license are
    all unaffected, verified over a real session.

    The gap that let this ship is closed too: no test crossed the protocol
    boundary, which is also why the wrong -32602 documentation survived so long.
    tests/test_protocol_errors.py drives a real client session over in-memory
    streams — nine tests, three of them verified to fail against the previous
    implementation. One asserts the error envelope still validates against the
    tool's outputSchema, because returning a CallToolResult bypasses the SDK's
    own output validation on that path.

  • jsonschema added to the dev extras. It arrives transitively via mcp, but
    depending on that is exactly how the PyYAML gap reached CI.

Fixed (SEC-018)

  • Three string fields had no length bound. collection_id (stac.py),
    origins (geocoding.py) and layers (wmts.py) carried a pattern but no
    max_length, and a pattern constrains the charset, not the size — a
    multi-kilobyte value of legal characters passed validation and was forwarded
    upstream. collection_id is the sharp one: it is interpolated into an
    upstream URL path. Bounds added (128 / 128 / 512).

    The property is now enforced rather than the instances: a sweep walks every
    *Input model across the ten tool modules and fails if a string field ships
    without a bound. It asserts it found the models first, so it cannot pass
    vacuously.

    The sweep found three fields the audit did not name
    ListLayersInput.source, LookupPostalCodeInput.postal_code,
    FindCommuneInput.district. All three are genuinely bounded by anchored
    fixed-width patterns, so they are exempt — but the exemption is checked: a
    second test asserts every exempt field has an anchored pattern with no
    unbounded quantifier, so the exempt set cannot become a way to silence the
    first test.

  • origins is now an actual enum. Its description promised seven values
    while its pattern accepted any lowercase-alphanumeric-comma string. A
    Literal cannot express a comma-separated list, so a field_validator
    checks each member and the error names the allowed set. This changes the
    tool's input schema, so tool-hashes.json was regenerated.

  • TEXT_PATTERN's charset is documented as deliberate. It admits ; &
    / % because real Swiss addresses contain them ("Rue de l'Hôpital 3/5").
    The comment now states the conditions that make that safe — no shell, no SQL,
    httpx does the parameter encoding — so a future tool that builds a command by
    interpolation is visibly out of contract rather than silently covered.

Fixed (CH-004)

  • Third-party licences were lost on the error path. ToolResponse.error()
    took a license parameter that 14 call sites never passed, so it fell back to
    the swisstopo default: OpenStreetMap data went out labelled
    Swiss Open Government Data (opendata.swiss) instead of ODbL, and the
    cantonal ÖREB terms — the most restrictive licence in the server — the same
    way. Relabelling ODbL drops the share-alike obligation, so this was a licence
    misstatement, not a missing field.

    Fixed by removing the possibility rather than the instances. ok() and
    error() now derive the licence from the source (LICENSE_BY_SOURCE) unless
    a caller states one, so omitting it produces the correct attribution instead
    of a wrong one. All 14 sites are corrected without being edited.

    Two guards, since derivation only protects the sites that stay silent:

    • a test that collects every *_SOURCE constant by introspection and fails if
      one has no licence mapping;
    • an AST sweep over src/ that fails if a call site pairs a source with
      another source's licence, or states a literal licence that is not on a
      declared override list. It asserts it found more than 50 call sites, so it
      cannot pass vacuously.

    Both were verified to fail against a deliberately introduced defect. The sweep
    also caught a change made during this remediation, which is the point.

  • list_available_layers now carries a per-record licence. Its envelope
    licence is necessarily composite; "gemischt — siehe je Layer" previously
    pointed at nothing the caller could read.

Fixed (OBS-002)

  • Two paths handed the model text the server should not have forwarded.

    • overpass.py returned up to 300 characters of any upstream body containing
      "error" straight into the tool summary. A real Overpass error page echoes
      the submitted query and names server-side paths, so this disclosed
      infrastructure and gave a third-party instance a channel into the model's
      context window. The body is now classified against a fixed signature table
      (timeout / out of memory / rate-limited / parse error, else a generic
      message) and only those strings are returned; the body itself goes to
      stderr. The HTTP-200 remark path got the same treatment — it was
      forwarding upstream text too, which the audit did not name.
    • A blocked-egress PermissionError was returned verbatim, disclosing the
      complete ten-host allow-list or the internal address a name resolved to.
      It now has its own branch in handle_api_error: detail to the log under
      egress_blocked, a fixed "Ziel nicht erlaubt (Egress-Richtlinie)" message
      to the caller. ValueError keeps its message — those are this server's own
      validation strings and masking them would remove real guidance.

    Nine regression tests, driven by a body shaped like a genuine Overpass error
    page. mask_error_details=True remains unreachable: re-verified that
    FastMCP.__init__ has no such parameter in mcp 1.28.1, so it would require
    switching to the standalone fastmcp package.

  • OSM errors were labelled with the swisstopo licence (CH-004, partial).
    The six ToolResponse.error(...) sites in overpass.py now pass
    license=OSM_LICENSE, so ODbL data is no longer attributed as Swiss OGD on
    the error path. The equivalent sites in openplz.py and oereb.py are
    untouched and CH-004 stays open.

Fixed (SDK-001)

  • The shared HTTP client was owned by an MCP session, not by the process.
    Under --http the SDK runs the FastMCP lifespan once per session, not
    once per process — measured on a running server: 0 startups at boot, 3 after
    three initialize POSTs, and 1 shutdown after a single DELETE /mcp while
    two sessions were still open. Two consequences, both reachable with two
    concurrent clients: each new session overwrote the previous session's client,
    and the first session to disconnect closed that client and shut tracing down
    for everyone still connected, silently degrading them to a fresh
    httpx.AsyncClient per tool call — the exact anti-pattern the shared client
    exists to avoid.

    The client and tracing now live behind a reference-counted
    server_resources() context. The session lifespan enters it, so N sessions
    build the resources once; build_http_app wraps the Starlette lifespan in the
    same context, so the process holds one reference for as long as it is serving
    and no session teardown can reach zero. Same server after the change: 1
    startup at boot, still 1 after three sessions and a DELETE, 1 shutdown on
    SIGTERM.

    This also closes the setup_tracing/shutdown_tracing idempotency gap by
    construction, and corrects the lifespan docstring, which asserted a
    per-process invariant the HTTP transport does not provide. Four regression
    tests added; three verified to fail against the previous implementation.

Added

  • DNS-pinning transport, opt-in (SEC-005). SWISSTOPO_PIN_DNS=true makes
    the client connect to the address the SSRF guard already vetted, closing the
    rebinding window between check and connect. SNI and the Host header stay on
    the hostname, so certificate validation is unaffected.
    • Off by default, because it rewrites the target of every outbound
      request. It is also automatically inert behind a forward proxy, where the
      proxy resolves the name and pinning would only break CONNECT.
    • The handshake is verified. Against api3.geo.admin.ch and
      geodesy.geo.admin.ch: connecting to the IP with the hostname as SNI
      completes a TLSv1.3 handshake with a matching SAN, while the same
      connection presenting the IP as the server name fails with
      CERTIFICATE_VERIFY_FAILED: IP address mismatch — the failure mode the SNI
      preservation exists to avoid. live-marked tests cover both, plus the
      httpx→httpcore chain that carries the extension, so an SDK upgrade that
      drops it fails the nightly run rather than silently disabling the control.
  • Egress-proxy ACL (SEC-021). deploy/smokescreen-acl.yaml carries the same
    ten hosts as ALLOWED_HOSTS and is generated from it by
    scripts/render_egress_acl.py, with a CI gate. A Kubernetes NetworkPolicy
    cannot match on hostname, so this is what a real per-host network-layer
    control needs.
  • Egress-proxy deployment manifest (SEC-021). deploy/egress-proxy.yaml
    adds the Smokescreen sidecar, the HTTPS_PROXY wiring and a NetworkPolicy
    permitting DNS plus proxied HTTPS only, replacing the permissive one.
    Applying it stays a deliberate operator step — it changes how every request
    leaves the pod — and the apply order is documented in the file.
    Note the interaction: the proxy and DNS pinning are mutually exclusive by
    design, since behind a proxy the proxy resolves the name and pinning goes
    inert. Proxy for a cluster deployment, pinning for direct egress.
  • swisstopo_oereb_at — ÖREB restrictions at a coordinate in one call
    (23 → 24 tools, ARCH-007). It resolves the EGRID internally: that identifier
    is an upstream artefact, not something a caller asked for, so requiring a
    second tool call for it was the chain the audit flagged.
    swisstopo_get_egrid stays for callers who want the parcel ID itself.
  • An explicit precedence rule in the server instructions (ARCH-007). Bauzone,
    Gemeinde and ÖREB each name the direct tool and state when the generic one
    applies, cross-referenced from the competing tool descriptions — the
    instructions string alone is not reliably consulted per selection decision.
  • A Tool budget and aggregation section in both READMEs (ARCH-006) that
    argues each tool cluster rather than stating the count. The five api3 tools
    are not merged: their argument shapes are disjoint, so a discriminated
    union would relocate the same decision rather than remove it. That remains an
    open candidate for a future major release, and the README says so.
  • Audit remediation batch 2 — six findings closed, two partially (ARCH-003,
    ARCH-011, OPS-001, OPS-003, SCALE-003, CH-004; SEC-005 and SEC-021 partially).
    • ARCH-003: ToolResponse gained a note field, and searches that find
      nothing now return an actionable next step instead of a bare negative.
      Additive — no client breaks, tool hashes unaffected.
    • OPS-001: .github/workflows/live-test.yml runs the live suite nightly
      and opens a deduplicated issue on failure. Keeping it out of PR CI stays
      right; never running it at all meant an upstream contract change would have
      surfaced as a user-facing bug.
    • SCALE-003: deploy/haproxy.cfg is a real, mountable config with the
      stick-table keyed on Mcp-Session-Id. It was previously a comment inside
      the Ingress manifest, which is not deployable.
    • OPS-003: both READMEs stated Phase 1 while docs/roadmap.md said 2.5.
      The roadmap is now named as the single authority, and the README carries a
      status table plus the phase-advance criteria.
    • CH-004: both READMEs now carry a source-and-licence table covering all
      eight sources, including the three that were missing.
    • ARCH-011: the flat module layout is now argued in both READMEs rather
      than restructured — each module maps to one upstream API family, which is
      the axis this code varies along.
  • OpenTelemetry tracing (OBS-006). structlog already reported a
    duration_ms per tool call; tracing adds the causal view, so a slow tool call
    can be attributed to the upstream request inside it.
    • Off unless configured. setup_tracing() is a no-op when
      OTEL_EXPORTER_OTLP_ENDPOINT is unset, so the local stdio workflow is
      unaffected and emits nothing. The packages ship as regular dependencies so
      a deployment needs no separate install.
    • httpx is auto-instrumented, so upstream requests nest under the tool span.
      Initialisation runs before the shared client is created — the
      instrumentation patches the client class, and a client built earlier would
      never be traced.
    • Spans carry mcp.tool.name and mcp.tool.result.is_error. A handled error
      returns normally, so the flag is read from the response envelope rather
      than inferred from an exception.
    • Tool arguments are never span attributes. Coordinates, addresses and
      search terms are user input and do not belong in an observability backend.
      A test asserts a geocoding argument does not appear in any attribute.
    • Configured via the standard OTEL_ variables (documented in
      .env.example, docs/deployment.md and deploy/kubernetes.yaml) rather
      than a SWISSTOPO_-prefixed setting, so existing OTel tooling works
      unchanged.
  • Three convenience tools ported from swiss-geodata-mcp (20 → 23 tools),
    completing PR 3 of docs/merge-plan-swiss-geodata-mcp.md:
    • swisstopo_zoning_at — harmonised building zone at a point
      (ch.are.bauzonen) in one call, without a preceding layer lookup. The ARE
      layer is a federal synthesis and not legally binding; that caveat is
      carried on every result record, not just in the prose summary, so a client
      reading results cannot lose it.
    • swisstopo_municipality_at — municipality, canton and official BFS commune
      number at a point (swissBOUNDARIES3D). The layer carries one polygon per
      historical year, so the current-year record is selected.
    • swisstopo_layer_info — a layer's queryable fields plus its legend,
      revealing which search_field values swisstopo_find_features accepts. A
      missing legend is not fatal: the fields are still returned.
  • Tool budget raised from 20 to 25 to accommodate the consolidation. The
    README stated 18 while the CHANGELOG stated 20; both now say 25.
    These three tools also address the open remediation on audit finding
    ARCH-007, which asks for higher-level tools that resolve a common case in a
    single call instead of a discovery chain.
  • bfs_commune_number is normalised to an integer across tools. The upstream
    layers disagree — ch.are.bauzonen serves it as a string, swissBOUNDARIES3D
    as an int — and it is the join key to swiss-statistics-mcp and
    zurich-opendata-mcp, so it must not depend on which tool produced it.
  • LV95 coordinate input on the point-based tools (swisstopo_get_height,
    swisstopo_identify_features, swisstopo_get_egrid). Each now takes either
    lat/lon (WGS84) or easting/northing (LV95, EPSG:2056) via the shared
    SwissPointInput contract; supplying both, neither, or half a pair is a
    validation error. swisstopo_elevation_profile gains
    coordinate_system="lv95" for LV95 support points.
    This is PR 2 of docs/merge-plan-swiss-geodata-mcp.md and the prerequisite
    for migrating LV95-native clients off swiss-geodata-mcp.
    • Passing WGS84 degrees in the LV95 fields is rejected with a message naming
      the mistake, rather than being converted into a point in the wrong place.
    • Height results now carry both lat/lon and easting/northing.
    • Existing lat/lon callers are unaffected.
  • swisstopo_convert_coordinates — official LV95<->WGS84 transformation via
    the swisstopo REFRAME service
    (19 → 20 tools, exactly at the 20-tool
    budget). This is the first step of the swiss-geodata-mcp consolidation
    described in docs/merge-plan-swiss-geodata-mcp.md, and the one capability
    that server had which this one lacked.
    • New egress host geodesy.geo.admin.ch (SEC-021 allow-list + docs).
    • The input validator rejects swapped axes and out-of-system magnitudes
      instead of silently returning a point in the wrong place — REFRAME labels
      both inputs easting/northing, so for wgs84_to_lv95 the easting
      carries the longitude, reversing the lat/lon order used elsewhere.
    • lv95_to_wgs84 results additionally carry lat/lon so they can be passed
      straight to the other tools.

Changed

  • The OEREB canton list is read once at startup instead of on every call.
    This is what ARCH-004 asks for, but it means changing
    SWISSTOPO_OEREB_CANTONS now requires a restart.
  • All 23 tool annotations use the typed ToolAnnotations instead of plain
    dicts, and a mypy src/ gate runs in CI (SDK-002). Four handlers in
    rest_api.py were annotated -> str while returning ToolResponse;
    nothing caught it, hence the gate.
  • Fixed the broken HTTP transport (SDK-004 / SCALE-001).
    TransportSecuritySettings is now passed to FastMCP(), so the SDK's
    DNS-rebinding protection is told the deployment's real hosts and origins
    instead of falling back to its loopback-only default. Before this, a
    deployment behind an ingress answered 403 to a configured origin and
    421 to the forwarded Host on every /mcp request, while /healthz
    returned 200 — so the Kubernetes readiness probe stayed green and the
    failure was invisible.
    • New SWISSTOPO_ALLOWED_HOSTS setting, documented in .env.example,
      docs/deployment.md and deploy/kubernetes.yaml.
    • Loopback entries use the SDK's :* wildcard-port form, because --port
      overrides the configured port at runtime.
    • DNS-rebinding protection stays enabled — SEC-005 depends on it. The fix
      is to name the right hosts, never to switch the check off. Unconfigured
      origins and hosts are still rejected.
    • tests/test_http_app.py now drives the ASGI app end to end. Its previous
      tests inspected middleware kwargs and passed throughout the outage.
  • Re-audit run 2026-07-27T125314-Z against the 68-check catalogue
    (audits/). 44 checks applicable, 22 pass / 20 partial / 2 fail;
    production_ready: false, blocked on SEC-022. This is not comparable to
    the previous run's 36/36: the old profile still said is_cloud_deployed: false although the Dockerfile and deploy/kubernetes.yaml had been added as
    remediation for SEC-007/SCALE-002, so 8 checks — most of SCALE among them —
    had never been evaluated. The checks were also re-verified from source rather
    than inherited.
  • Highest-impact open finding (SDK-004 / SCALE-001, pre-existing):
    transport_security is never passed to FastMCP(), so the SDK keeps its
    localhost-only allow-list. Reproduced against a running instance: POST /mcp
    returns 403 for an origin configured via SWISSTOPO_ALLOWED_ORIGINS and
    421 for the Host the shipped Ingress forwards, while /healthz returns
    200. The HTTP transport is unusable in any non-localhost deployment and the
    Kubernetes probe masks it.
  • Fixed, in this release, the three findings this consolidation itself caused:
    • SwissPointInput carried an empty model_config, so the base class
      accepted extra fields and coerced strings. Every shipped subclass
      re-declared the strict config, so no tool was permissive — but the base was
      a trap (SEC-018).
    • The ARE, swissBOUNDARIES3D and REFRAME sources set source but never
      license, so ARE data silently inherited SWISSTOPO_LICENSE despite coming
      from a different federal office. Added the missing licence constants and the
      license parameter that ToolResponse.error() lacked entirely (CH-004).
    • Stale 18-tool budget references survived the raise to 25 in
      geodata.py and docs/geodaten-erweiterung-phase1.md (ARCH-006).
  • The local polynomial helpers (wgs84_to_lv95 / lv95_to_wgs84) remain the
    internal fast path for every height/profile/identify call and were not
    replaced by REFRAME. Measured against the official service at four points
    across Switzerland they deviate by 0.05–0.20 m — well below the tolerance
    those tools operate at — so routing them through the network would have added
    a roundtrip per call for irrelevant precision. A live-marked test guards the
    assumption by failing if the deviation ever exceeds one metre.

Fixed

  • The generated egress-proxy ACL enforced nothing (SEC-021).
    scripts/render_egress_acl.py emitted the host entries at two-space indent
    under a four-space allowed_domains: key, so deploy/smokescreen-acl.yaml
    parsed with allowed_domains: null and ten hostnames promoted to siblings of
    the service object. A Smokescreen sidecar reading it would have failed to
    unmarshal or allowed nothing — while --check reported the file up to date,
    because it diffs against the output of the same renderer.
    tests/test_egress_allowlist.py::TestGeneratedProxyAcl now loads the
    committed artefact with yaml.safe_load and asserts the ten hosts are where
    the proxy would look for them; verified to fail against the old output.
    Also dropped the --config-file argument in deploy/egress-proxy.yaml, which
    pointed at a file the documented ConfigMap does not contain.
  • The last stale tool-budget number (docs/geodaten-erweiterung-phase1.md) now
    points at the README instead of carrying a figure of its own, so it cannot go
    stale again.
  • Two security documents overstated the posture.
    • SECURITY.md credited follow_redirects=False to SEC-005 (DNS rebinding),
      which it does not address. It belongs to SEC-004 and is listed there now.
      An honest row states that DNS pinning is not implemented, names the
      residual rebinding window, and notes the network-layer compensation applies
      to the Kubernetes deployment only.
    • docs/network-egress.md claimed no NetworkPolicy was shipped while
      deploy/kubernetes.yaml contains one. It now states precisely what that
      policy does (CIDR and port restriction) and does not do (per-host matching —
      a NetworkPolicy cannot match on hostname).
    • A parametrised test asserts every ALLOWED_HOSTS member appears in the
      documentation table and that the table lists no host the code does not know,
      so this particular drift is a CI failure rather than a review item.
  • sr=2056 produced silently wrong height, profile and identify results.
    The parameter was meant to select the input coordinate system, but the WGS84
    field bounds rejected LV95 magnitudes, so the only way to reach the branch was
    to pass WGS84 degrees with sr=2056 — which sent those degrees upstream
    labelled as LV95 metres. sr now accepts 4326 only and points at the new
    easting/northing fields, turning a wrong answer into an explicit error.
    This narrowed an input schema, so it is a breaking change under the semver
    rule now recorded in CONTRIBUTING.md; clients should re-approve.
  • server.json declared version 0.1.3 while the package was at 0.2.0, which
    would have published a wrong version to the MCP Registry.

Security

  • SEC-014 and SEC-015 closed as enforced deferrals. Both concern controls
    that belong to an MCP gateway aggregating the portfolio — no single server can
    allow-list or detect poisoning across a set it cannot see. That reasoning was
    already in SECURITY.md; what was missing is that its premises were only
    asserted. They are now checked:

    • tests/test_tool_hygiene.py fails if any tool stops being read-only or
      becomes destructive. SEC-014's risk-bounding argument depends on that and
      can no longer go stale unnoticed.
    • The same file scans this server's own tool descriptions for invisible
      characters and override phrasing in German, French and English. The
      descriptions here are German; an English-only pattern list would miss them.
      This is a self-scan, not cross-server detection, and says so.
    • SECURITY.md no longer implies nothing applies until a gateway exists, and
      gains re-evaluation triggers for a non-read-only tool and for
      config-driven or remotely-sourced descriptions.
  • Audit remediation batch 1 — five findings closed (SEC-004, SEC-018,
    SDK-002, ARCH-004, ARCH-012). No breaking changes for clients.

    • SEC-004 (critical): assert_host_allowed only ever checked the
      hostname, so an allow-listed host over cleartext http:// passed. It now
      validates the scheme first and adds a resolved-IP guard that blocks any
      allow-listed name answering with a private or link-local address. The guard
      hangs off the same function, so it also covers the two direct-client call
      sites in oereb.py. Resolution is cached per host; a resolution failure
      is deliberately non-fatal so httpx surfaces the real connection error.
    • SEC-018: validate_sr() existed but was never called — an arbitrary
      sr int was forwarded straight upstream. It is now wired into the three
      sr fields, and length bounds were added to the identifier fields.
    • ARCH-004: transport and oereb_cantons became Settings fields.
      --http still wins on the command line, but SWISSTOPO_TRANSPORT is the
      deployment path, and oereb.py no longer reads os.environ directly —
      which had contradicted config.py's own docstring.
    • ARCH-012: both READMEs now name the concrete negotiated protocol
      version (2025-11-25) and an update policy, and
      tests/test_protocol_version.py fails if an SDK bump moves it.
  • Adversarial re-audit — run 2026-07-27T162602-Z. Same catalogue, same 44
    applicable checks as 2026-07-27T125314-Z, so the two compare directly:
    24 pass / 20 partial / 0 fail (previously 22 / 20 / 2). The auditing
    agents were briefed to refute the preceding remediation claims rather than
    confirm them.

    Six of those claims did not survive. Recorded here because the remediation
    notes above are what they contradict:

    • SEC-021 — the generated deploy/smokescreen-acl.yaml is structurally
      invalid: two-space indent under a four-space key makes
      services[0].allowed_domains parse as null. The CI gate diffs against
      the same renderer and is blind to it by construction.
    • ARCH-007 — the claim that swisstopo_get_egrid no longer describes
      itself as a precursor was false when written; a replacement had silently
      failed. Verified and fixed since. README workflow sections and the absent
      parallelisation in geodata.py remain open.
    • OBS-006 — tool arguments are exported: the httpx auto-instrumentation
      the module enables emits http.url with the full query string. True of the
      span we write, false of the system we configure.
    • CH-004ToolResponse.error() gained a license parameter that 14
      call sites never pass, so ODbL data is labelled as Swiss OGD.
    • SEC-018 — three string fields still lack max_length.
    • ARCH-003 — structured note reaches 5 of ~25 bare-negative sites.

    Four findings are new: SDK-001 (under --http the lifespan runs per
    session, not per process; closing one session nulls the shared client for the
    others), OBS-001 (the protocol isError flag is never set), OBS-002
    (overpass.py forwards up to 300 characters of an upstream error body) and
    SCALE-003 (haproxy.cfg never populates its stick table — stick on
    stores from the request, the session id is minted in the response).

    The run's production_ready flag reads YES; it gates only on hard fail,
    and two partial findings are critical severity. See
    audits/2026-07-27T162602-Z-swisstopo-mcp/audit-report.md §1a.

Documentation

  • SECURITY.md / SECURITY.de.md: corrected stale tool counts (13/23 → 24),
    the phase declaration, and the DNS-pinning row, which said Not implemented
    for a control that has been shipping since the previous batch. The error-handling
    row no longer claims upstream bodies never reach the model.
  • docs/roadmap.md is now the single authority for phase state; the README and
    roadmap previously named each other, so neither was.
  • README.de.md gained the phase status table and advance criteria that went
    into the English README only.
  • Both READMEs: the cadastre workflow now names swisstopo_oereb_at, and the
    error-handling section no longer claims JSON-RPC -32602 for protocol errors
    — a runtime probe against mcp 1.28.1 shows they arrive as isError tool
    results.