diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e3037..5ed4b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (opt-in, one per tool) with a shared `conftest.py` (OPS-001). - `ctx: Context` injection in all tools + per-tool-call bound structured logging context (`tool`, `correlation_id`, `request_id`/`client_id`) — SDK-003 / OBS-003. -- OpenTelemetry now configures a real `TracerProvider` + OTLP exporter when - enabled (`[otel]` extra, `MCP_OTEL_ENABLED`) — OBS-006. +- **Structured tool output (SDK-002):** all 6 tools now declare typed Pydantic + output schemas and return `structuredContent` alongside the curated Markdown + (`content`) — a hybrid `CallToolResult`, so machine consumers get a validated + schema with no loss of the human-readable output. +- **OpenTelemetry on by default (OBS-006):** `MCP_OTEL_ENABLED` now defaults to + on; a silent no-op when the `[otel]` extra isn't installed (base installs and + stdout are unaffected). `TracerProvider` + OTLP exporter + + Starlette/httpx auto-instrumentation; set `MCP_OTEL_ENABLED=0` to disable. +- **DNS-pinned HTTP transport** (`_PinnedNetworkBackend`): the host is resolved + exactly once, the resolved IP is validated and the TCP connection pinned to it, + while TLS SNI/cert verification still use the hostname — eliminates the + resolve/connect TOCTOU (SEC-005). +- `deploy/haproxy.cfg`: reference sticky-session (Mcp-Session-Id stick-table + + TTL) config for the horizontal-scaling path (SCALE-002/003). ### Changed - Console entrypoint is now `bag_epl_mcp.server:main` (transport-aware). @@ -64,8 +76,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 SEC-016, SEC-019, SEC-009, OBS-001/002 (Phase 1); SEC-007, SCALE-004, SCALE-006, SDK-001, OBS-003 (Phase 2); and SEC-018, SEC-022, ARCH-002, ARCH-012, SDK-002, ARCH-003, CH-004, OBS-006, OPS-001/002/003 (Phase 3); - and SDK-003, OBS-003 (post-re-audit follow-up). See `docs/audit/2026-06-01/` - and `docs/audit/2026-06-01-reaudit/`. + and SDK-003, OBS-003, SEC-005, SDK-002, OBS-006 (post-re-audit follow-up). See + `docs/audit/2026-06-01/` and `docs/audit/2026-06-01-reaudit/`. ## [0.1.0] - 2026-04-13 diff --git a/README.md b/README.md index cc6bbbb..9ef80a7 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,10 @@ Or with `uvx`: - `MCP_PORT=8000` (or Render's `$PORT`) - *(optional)* `MCP_CORS_ORIGINS='["https://claude.ai"]'` to extend the browser CORS allow-list + - *(optional)* OpenTelemetry tracing is **on by default** but a no-op unless + the tracing deps are installed — build with `pip install -e ".[http,otel]"` + and point `OTEL_EXPORTER_OTLP_ENDPOINT` at your collector. Set + `MCP_OTEL_ENABLED=0` to disable. 5. Start command: `python -m bag_epl_mcp.server` 6. In claude.ai under Settings \u2192 MCP Servers, add: `https://your-app.onrender.com/mcp` @@ -214,6 +218,7 @@ every `mcp` SDK bump \u2014 see the versioning policy in [`docs/ROADMAP.md`](doc - **Rate limits:** The SL website (sl.bag.admin.ch) is a public Angular SPA; the server enforces a 30s timeout per request. Use `limit` parameters conservatively. - **Data freshness:** Phase 1 tools link to live BAG sources. No caching is performed by this server. - **Data licence (OGD-CH):** The underlying BAG/Fedlex data is Swiss Open Government Data, licensed **CC BY 4.0**. Tool outputs carry a `source` / `provenance` block (JSON) or a source-and-licence footer (Markdown) so attribution is preserved. +- **Structured output:** every tool returns both a human-readable Markdown/JSON block (`content`) and a typed `structuredContent` validated against a per-tool output schema, so MCP clients can consume results programmatically without parsing prose. - **Terms of service:** Data is subject to the ToS of [sl.bag.admin.ch](https://sl.bag.admin.ch), [bag.admin.ch](https://www.bag.admin.ch), and [fedlex.admin.ch](https://www.fedlex.admin.ch). - **No guarantees:** This is a community project, not affiliated with the BAG or any government entity. Availability depends on upstream sources. diff --git a/deploy/haproxy.cfg b/deploy/haproxy.cfg new file mode 100644 index 0000000..d5e00dd --- /dev/null +++ b/deploy/haproxy.cfg @@ -0,0 +1,41 @@ +# SCALE-002 / SCALE-003 — Sticky-Session Load Balancing for Streamable HTTP +# +# Streamable-HTTP MCP sessions are stateful: follow-up requests carry an +# `Mcp-Session-Id` header and MUST reach the backend instance that created the +# session. Phase 1 runs as a SINGLE instance (no LB needed). This config is the +# reference for the multi-instance scaling path: it routes by `Mcp-Session-Id` +# via a stick-table so a session always lands on the same backend, with an +# explicit TTL aligned to the session lifetime. + +global + log stdout format raw local0 + maxconn 4096 + +defaults + mode http + log global + option httplog + timeout connect 5s + timeout client 120s # SSE/streamable responses are long-lived + timeout server 120s + timeout tunnel 1h # keep streaming connections open + +frontend mcp_in + bind *:8080 + default_backend mcp_backends + +backend mcp_backends + balance roundrobin + + # Stick-table keyed on the Mcp-Session-Id header. + # expire == session TTL (keep in sync with the server's session lifetime); + # size 100k supports ~100k concurrent sessions (SCALE-003). + stick-table type string len 64 size 100k expire 30m + stick on req.hdr(Mcp-Session-Id) + + # Health check hits the dedicated /healthz endpoint (see SCALE-004). + option httpchk GET /healthz + + server mcp1 10.0.0.11:8000 check + server mcp2 10.0.0.12:8000 check + # add further replicas here; sticky routing keeps each session on one backend diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 267ec14..280a376 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -46,24 +46,30 @@ MCP SDK's default session manager; the server adds no custom `user_id:session_id binding because there is no user identity to bind and no confidential per-session data to protect. -**Operational requirement:** run Phase 1 as a **single instance**. Sticky-session -load balancing / shared session store (SCALE-002/003) only become necessary if -the deployment is horizontally scaled. Introducing authentication or per-user -state in a later phase **must** re-introduce signed session binding and trigger -a re-audit. +**Operational requirement:** run Phase 1 as a **single instance**. When scaling +horizontally, sticky-session load balancing keyed on the `Mcp-Session-Id` header +is required so stateful Streamable-HTTP sessions stay on one backend +(SCALE-002/003) — a reference HAProxy stick-table config with an explicit +session TTL is provided in [`../deploy/haproxy.cfg`](../deploy/haproxy.cfg). +Introducing authentication or per-user state in a later phase **must** +re-introduce signed session binding and trigger a re-audit. ## 4. Egress controls (SEC-004 / SEC-005 / SEC-021) -All outbound requests pass through `_assert_safe_url()` in `src/bag_epl_mcp/server.py` -before any HTTP call: +The fast pre-check `_assert_safe_url()` runs before any outbound call (HTTPS-only ++ host allow-list). The actual connection is made through a **DNS-pinned** +transport (`_PinnedNetworkBackend`) so the hostname is resolved **exactly once**, +the resolved IP is validated and the TCP connection is pinned to it, while TLS +SNI and certificate verification still use the original hostname (SEC-005, +eliminates the resolve/connect TOCTOU). Concretely: - **HTTPS only** — non-`https` schemes are rejected. - **Code-layer allow-list** — the host must be in the immutable `ALLOWED_HOSTS` frozenset (`sl.bag.admin.ch`, `www.bag.admin.ch`, `www.fedlex.admin.ch`). Default-deny. -- **Resolved-IP blocklist** — private, loopback, link-local and reserved IPs - are rejected (blocks SSRF pivots and the cloud metadata endpoint - `169.254.169.254`). +- **Single-resolution + pinned IP** — `_resolve_and_validate()` rejects private, + loopback, link-local and reserved IPs (blocks SSRF pivots and the cloud + metadata endpoint `169.254.169.254`) and returns the pinned address. **Defense-in-depth (deployment):** add a network-layer egress policy (e.g. Render egress rules / Kubernetes `NetworkPolicy`) restricting outbound diff --git a/docs/audit/2026-06-01-reaudit/RE-AUDIT-REPORT.md b/docs/audit/2026-06-01-reaudit/RE-AUDIT-REPORT.md index 5c879b0..738943d 100644 --- a/docs/audit/2026-06-01-reaudit/RE-AUDIT-REPORT.md +++ b/docs/audit/2026-06-01-reaudit/RE-AUDIT-REPORT.md @@ -3,13 +3,13 @@ > Re-audit with the [mcp-audit-skill](https://github.com/malkreide/mcp-audit-skill) (v1.0.0, 68-check catalog) after Phase 1–3 remediation (PRs #2, #3, #4). > Baseline: [`../2026-06-01/AUDIT-REPORT.md`](../2026-06-01/AUDIT-REPORT.md). All numbers derive from [`summary.json`](./summary.json). > -> **Follow-up (same day):** a post-re-audit remediation closed **SDK-003** and **OBS-003** and fully wired **OBS-006** — see §9. The headline figures below reflect that follow-up: **36 pass / 8 partial / 0 fail**. +> **Follow-up (same day):** a post-re-audit remediation closed **SDK-003**, **OBS-003**, **SEC-005** (DNS-pinning), **SDK-002** (typed output schemas + structuredContent) and **OBS-006** (tracing on by default), and added a sticky-session config for SCALE-002/003 — see §9. The headline figures below reflect that follow-up: **39 pass / 5 partial / 0 fail**. --- ## 1. Executive Summary -After three remediation phases plus a same-day follow-up, the server moved from **11 pass / 26 partial / 7 fail** to **36 pass / 8 partial / 0 fail** across the 44 applicable checks. **All 7 failed checks and 18 partials were resolved**, including the advertised-but-unimplemented cloud transport (the baseline's headline defect) and 3 of the 4 critical items. +After three remediation phases plus a same-day follow-up, the server moved from **11 pass / 26 partial / 7 fail** to **39 pass / 5 partial / 0 fail** across the 44 applicable checks. **All 7 failed checks and 21 partials were resolved**, including the advertised-but-unimplemented cloud transport (the baseline's headline defect) and 3 of the 4 critical items. **Production-readiness: `true`** for the documented **Phase-1, single-instance** deployment. There are **no failed checks** and no unaddressed criticals. The single remaining critical (SEC-009) is a **documented accepted-risk** (no authentication, public read-only data). The other 9 partials are either documented accepted-risk or deferred to Phase 2 / multi-instance scaling — none block a Phase-1 release. @@ -19,16 +19,16 @@ After three remediation phases plus a same-day follow-up, the server moved from | Status | Baseline (2026-06-01) | Re-audit (incl. follow-up) | Δ | |--------|:---:|:---:|:---:| -| Pass | 11 | **36** | **+25** | -| Partial | 26 | **8** | **−18** | +| Pass | 11 | **39** | **+28** | +| Partial | 26 | **5** | **−21** | | Fail | 7 | **0** | **−7** | -| Findings (partial+fail) | 33 | **8** | **−25** | +| Findings (partial+fail) | 33 | **5** | **−28** | | Findings by severity | Baseline | Re-audit | |---|:---:|:---:| | Critical | 4 | **1** (accepted-risk) | -| High | 16 | **3** (deferred) | -| Medium | 13 | **4** | +| High | 16 | **2** (deferred multi-instance) | +| Medium | 13 | **2** (accepted-risk) | | Low | 0 | 0 | Profile unchanged: dual transport · no auth · Public Open Data · read-only · Render + local-stdio. @@ -65,20 +65,17 @@ Profile unchanged: dual transport · no auth · Public Open Data · read-only · --- -## 4. Remaining Findings (8) — none blocking Phase 1 +## 4. Remaining Findings (5) — none blocking Phase 1 | ID | Sev | Status | Disposition | Note | |----|----|--------|-------------|------| | SEC-009 | critical | partial | **accepted-risk** | No auth → no session binding by design (public read-only, single instance). Documented in `docs/SECURITY.md §3`. Re-introduce signed binding if auth is ever added. | -| SEC-005 | high | partial | **deferred** | Allow-list + IP blocklist done; full DNS-pinning (reuse one resolution for the TCP connection) pending. Low residual risk (fixed admin.ch hosts). | -| SCALE-002 | high | partial | **deferred (multi-instance)** | Sticky sessions / shared store only needed when scaled; single-instance documented. | -| SCALE-003 | high | partial | **deferred (multi-instance)** | Edge-LB session routing only for >1 replica. | -| SDK-002 | medium | partial | **by-design** | Consistent JSON envelope added; return annotation kept `str` to preserve Markdown UX. | +| SCALE-002 | high | partial | **deferred (multi-instance)** | Sticky sessions only needed when scaled; reference config in `deploy/haproxy.cfg`, full pass needs a real multi-instance failover test. | +| SCALE-003 | high | partial | **deferred (multi-instance)** | Edge-LB session routing only for >1 replica; `deploy/haproxy.cfg` documents the routing + TTL. | | SEC-014 | medium | partial | accepted-risk | No gateway allow-list; documented (single public read-only server). | | SEC-015 | medium | partial | accepted-risk | No pre-flight tool-poisoning detection; static own tools. | -| OBS-006 | medium | partial | opt-in | OTel fully wired (TracerProvider + OTLP + auto-instrumentation) but off by default; see §9. | -> **SDK-003** and **OBS-003** were closed in a same-day follow-up (§9). +> **SDK-003**, **OBS-003**, **SEC-005**, **SDK-002** and **OBS-006** were closed in a same-day follow-up (§9). **Recommended next steps (optional, non-blocking):** implement DNS-pinning (SEC-005) and `ctx`-based per-call logging (SDK-003 + OBS-003) in a small follow-up; address SCALE-002/003 only when moving to multi-instance; configure OTel at deploy time if tracing is desired. @@ -124,9 +121,12 @@ After the re-audit, the cleanly-fixable open items were addressed in a follow-up |----|----|:---:|--------| | SDK-003 | medium | partial → **pass** | `ctx: Context` injected into all 6 tools; per-call context bound to the structured logger. | | OBS-003 | medium | partial → **pass** | Per-tool-call bound logging context (`tool`, `correlation_id`, `request_id`/`client_id` when a session is active). | -| OBS-006 | medium | partial → **partial (wired)** | `_init_otel` now configures a real `TracerProvider` + OTLP exporter (+ Starlette/httpx auto-instrumentation). Still opt-in via `[otel]` + `MCP_OTEL_ENABLED`. | +| SEC-005 | high | partial → **pass** | DNS-pinned transport (`_PinnedNetworkBackend`): single resolution, IP pinned for the TCP connection, TLS/cert still validated against the hostname. Verified end-to-end against a real host. | +| SDK-002 | medium | partial → **pass** | All 6 tools now declare typed Pydantic output schemas and return `structuredContent` **plus** the curated Markdown in `content` (hybrid via `CallToolResult` — no UX loss). | +| OBS-006 | medium | partial → **pass** | OpenTelemetry on by default (`MCP_OTEL_ENABLED=0` to disable); silent no-op when the `[otel]` extra isn't installed, so base installs are unaffected and stdout stays clean. | +| SCALE-002/003 | high | partial (improved) | Reference HAProxy sticky-session config (`deploy/haproxy.cfg`); full pass needs a real multi-instance failover test. | -**Updated totals: 36 pass / 8 partial / 0 fail.** Remaining 8 partials: SEC-009 (critical, accepted-risk), SEC-005 / SCALE-002 / SCALE-003 (high, deferred — DNS-pinning + multi-instance), SDK-002 (by-design), SEC-014 / SEC-015 (accepted-risk), OBS-006 (opt-in). None block a Phase-1 release. +**Updated totals: 39 pass / 5 partial / 0 fail.** Remaining 5 partials: SEC-009 (critical, accepted-risk), SCALE-002 / SCALE-003 (high, deferred — multi-instance), SEC-014 / SEC-015 (medium, accepted-risk). None block a Phase-1 release. ## 8. Sign-Off diff --git a/docs/audit/2026-06-01-reaudit/summary.json b/docs/audit/2026-06-01-reaudit/summary.json index 98bc0fb..70d08d9 100644 --- a/docs/audit/2026-06-01-reaudit/summary.json +++ b/docs/audit/2026-06-01-reaudit/summary.json @@ -33,35 +33,32 @@ "checks_total": 68, "not_applicable": 24, "applicable": 44, - "pass": 36, - "partial": 8, + "pass": 39, + "partial": 5, "fail": 0, - "findings_total": 8 + "findings_total": 5 }, - "follow_up_2026_06_01": "Post-re-audit remediation closed SDK-003 and OBS-003 (ctx injection + per-call bound structured logging) and fully wired OBS-006 (TracerProvider + OTLP, opt-in). pass 34->36, partial 10->8.", + "follow_up_2026_06_01": "Post-re-audit remediation closed SDK-003, OBS-003 (ctx injection + per-call bound structured logging), SEC-005 (DNS-pinned transport: single resolution, IP pinned, TLS against hostname), SDK-002 (typed output schemas + structuredContent on all 6 tools, hybrid with curated Markdown) and OBS-006 (OpenTelemetry on by default; silent no-op without the [otel] extra). Added deploy/haproxy.cfg for SCALE-002/003 scaling path. pass 34->39, partial 10->5.", "delta_vs_baseline": { - "pass": "+25 (11 -> 36)", - "partial": "-18 (26 -> 8)", + "pass": "+28 (11 -> 39)", + "partial": "-21 (26 -> 5)", "fail": "-7 (7 -> 0)", - "resolved": ["ARCH-002","ARCH-003","ARCH-004","ARCH-009","ARCH-012","SDK-001","SDK-003","SDK-004","SEC-004","SEC-007","SEC-016","SEC-018","SEC-019","SEC-021","SEC-022","SCALE-001","SCALE-004","SCALE-006","OBS-001","OBS-002","OBS-003","CH-004","OPS-001","OPS-002","OPS-003"] + "resolved": ["ARCH-002","ARCH-003","ARCH-004","ARCH-009","ARCH-012","SDK-001","SDK-002","SDK-003","SDK-004","SEC-004","SEC-005","SEC-007","SEC-016","SEC-018","SEC-019","SEC-021","SEC-022","SCALE-001","SCALE-004","SCALE-006","OBS-001","OBS-002","OBS-003","OBS-006","CH-004","OPS-001","OPS-002","OPS-003"] }, "findings_by_severity": { "critical": 1, - "high": 3, - "medium": 4, + "high": 2, + "medium": 2, "low": 0 }, "production_ready": true, - "production_ready_rationale": "0 failed checks; all 4 baseline-critical items resolved except SEC-009, which is a documented accepted-risk (no auth, public read-only data, single-instance). The advertised cloud transport is implemented and smoke-tested. READY for the documented Phase-1, single-instance deployment. The 10 remaining partials are either documented accepted-risk or deferred to Phase 2 / multi-instance (SCALE-002/003, SEC-005) and do not block a Phase-1 release.", + "production_ready_rationale": "0 failed checks; all 4 baseline-critical items resolved except SEC-009, which is a documented accepted-risk (no auth, public read-only data, single-instance). The advertised cloud transport is implemented and smoke-tested. READY for the documented Phase-1, single-instance deployment. The 5 remaining partials are documented accepted-risk (SEC-009, SEC-014, SEC-015) or deferred to multi-instance (SCALE-002/003) and do not block a Phase-1 release.", "remaining_findings": [ {"id": "SEC-009", "severity": "critical", "status": "partial", "disposition": "accepted-risk", "note": "No auth/session binding by design; documented in docs/SECURITY.md (no user identity, public read-only data). Re-introduce signed session binding if auth/per-user state is ever added."}, - {"id": "SEC-005", "severity": "high", "status": "partial", "disposition": "deferred", "note": "Code-layer allow-list + resolved-IP blocklist in place; full DNS-pinning (single resolution reused for the TCP connection) not yet implemented. Low residual risk given fixed admin.ch hosts."}, - {"id": "SCALE-002", "severity": "high", "status": "partial", "disposition": "deferred-multi-instance", "note": "Single-instance Phase-1 deployment documented; sticky sessions / shared session store only needed when horizontally scaled."}, - {"id": "SCALE-003", "severity": "high", "status": "partial", "disposition": "deferred-multi-instance", "note": "Edge-LB Mcp-Session-Id routing only relevant for >1 replica; single-instance constraint documented."}, - {"id": "SDK-002", "severity": "medium", "status": "partial", "disposition": "by-design", "note": "JSON output now uses a consistent envelope (source/provenance/match_type/count/results), but tool return annotation stays `str` to preserve the curated Markdown UX."}, + {"id": "SCALE-002", "severity": "high", "status": "partial", "disposition": "deferred-multi-instance", "note": "Single-instance Phase-1 deployment; sticky sessions only needed when horizontally scaled. Reference HAProxy stick-table config in deploy/haproxy.cfg; full pass requires an actual multi-instance failover test."}, + {"id": "SCALE-003", "severity": "high", "status": "partial", "disposition": "deferred-multi-instance", "note": "Edge-LB Mcp-Session-Id routing only relevant for >1 replica; deploy/haproxy.cfg documents the routing + TTL for the scaling path."}, {"id": "SEC-014", "severity": "medium", "status": "partial", "disposition": "accepted-risk", "note": "No MCP gateway tool allow-list; documented in docs/SECURITY.md (single public read-only server, no enterprise context)."}, - {"id": "SEC-015", "severity": "medium", "status": "partial", "disposition": "accepted-risk", "note": "No pre-flight tool-poisoning detection; static own tools, documented decision."}, - {"id": "OBS-006", "severity": "medium", "status": "partial", "disposition": "opt-in", "note": "OpenTelemetry fully wired (TracerProvider + OTLP exporter + Starlette/httpx auto-instrumentation) but opt-in via [otel] extra + MCP_OTEL_ENABLED; off by default and stdio tool calls produce no spans."} + {"id": "SEC-015", "severity": "medium", "status": "partial", "disposition": "accepted-risk", "note": "No pre-flight tool-poisoning detection; static own tools, documented decision."} ], "release_proposal": { "current_version": "0.1.0", diff --git a/pyproject.toml b/pyproject.toml index 41a8bae..d1122a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ dependencies = [ "mcp[cli]>=1.27.2", "httpx>=0.28.1", + "httpcore>=1.0.0", "pydantic>=2.13.4", "pydantic-settings>=2.14.1", "structlog>=25.5.0", diff --git a/src/bag_epl_mcp/server.py b/src/bag_epl_mcp/server.py index 444cb64..fcd9c72 100644 --- a/src/bag_epl_mcp/server.py +++ b/src/bag_epl_mcp/server.py @@ -23,11 +23,12 @@ from typing import Literal from urllib.parse import urlsplit +import httpcore import httpx import structlog from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp.exceptions import ToolError -from mcp.types import ToolAnnotations +from mcp.types import CallToolResult, TextContent, ToolAnnotations from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -83,8 +84,10 @@ class ServerSettings(BaseSettings): port: int = Field(default=8000, validation_alias=AliasChoices("MCP_PORT", "PORT")) # SDK-004: explizite Origin-Allow-List statt Wildcard. cors_origins: list[str] = ["https://claude.ai"] - # OBS-006: OpenTelemetry-Tracing (opt-in, benoetigt das [otel]-Extra). - otel_enabled: bool = False + # OBS-006: OpenTelemetry-Tracing standardmaessig aktiv. Greift nur, wenn das + # [otel]-Extra installiert ist (sonst stiller No-op); mit MCP_OTEL_ENABLED=0 + # deaktivierbar. Exporter-Endpoint via OTEL_EXPORTER_OTLP_ENDPOINT. + otel_enabled: bool = True settings = ServerSettings() @@ -101,7 +104,7 @@ class ServerSettings(BaseSettings): async def _lifespan(_server: FastMCP) -> AsyncIterator[dict]: """Initialisiert den gepoolten HTTP-Client und raeumt ihn beim Shutdown ab.""" global _http_client - _http_client = httpx.AsyncClient(timeout=HTTP_TIMEOUT) + _http_client = _new_http_client() log.info("server_startup", transport=settings.transport) try: yield {"http_client": _http_client} @@ -146,9 +149,103 @@ class Provenance(BaseModel): phase: str = "Phase 1" -def _provenance(source: str, url: str) -> dict: +def _provenance(source: str, url: str) -> Provenance: """Erzeugt einen Provenance-Block (Quelle + Lizenz) fuer Tool-Antworten.""" - return Provenance(source=source, url=url).model_dump() + return Provenance(source=source, url=url) + + +# ─────────── Strukturierte Tool-Ausgaben / Output-Schemas (SDK-002) ───────────── +# Jedes Tool deklariert ein getyptes Envelope als Output-Schema und liefert es als +# `structuredContent` zurueck — zusaetzlich zur kuratierten Markdown-Ausgabe im +# `content`-Block (Hybrid, kein UX-Verlust). Siehe `_structured_result`. + +class BaseEnvelope(BaseModel): + """Gemeinsame Huelle aller strukturierten Tool-Ausgaben (SDK-002 / CH-004).""" + source: str + provenance: Provenance + + +class SLTreffer(BaseModel): + """Ein SL-Suchtreffer. ``extra='allow'``, da die Upstream-API zusaetzliche + Felder liefern kann, sobald sie oeffentlich ist.""" + model_config = ConfigDict(extra="allow") + name: str | None = None + + +class SLSucheEnvelope(BaseEnvelope): + suchbegriff: str + match_type: MatchType + count: int + results: list[SLTreffer] = Field(default_factory=list) + hinweis: str | None = None + direkt_link: str | None = None + fhir_status: str | None = None + + +class GGSLEnvelope(BaseEnvelope): + geburtsgebrechen_nr: str + status: str + erklaerung: str + link: str + rechtsgrundlage: str + hinweis: str + + +class MiGeLEnvelope(BaseEnvelope): + suchbegriff: str + status: str + erklaerung: str + link: str + rechtsgrundlage: str + migel_integration: str + match_type: MatchType = "none" + count: int = 0 + results: list[dict] = Field(default_factory=list) + + +class GesuchseingaengeEnvelope(BaseEnvelope): + beschreibung: str + link: str + direkt_link_bag: str + hinweis: str + + +class Gesetz(BaseModel): + kuerzel: str + titel: str + sr_nummer: str + fedlex: str + relevante_artikel: list[str] + + +class RechtskontextEnvelope(BaseEnvelope): + frage: str + gesetze: list[Gesetz] + wzw_kriterien: dict[str, str] + hinweis: str + + +class ServerInfoEnvelope(BaseEnvelope): + server: str + version: str + protocol_version: str + license: str + phase: str + tools: dict[str, str] + phasen: dict[str, str] + datenquellen: dict[str, str] + + +def _structured_result(text: str, envelope: BaseModel) -> CallToolResult: + """ + SDK-002: liefert beides zurueck — die kuratierte, menschenlesbare Ausgabe als + ``content`` (Markdown oder JSON je nach ``format``) **und** das getypte Envelope + als ``structuredContent`` (validiert gegen das Output-Schema des Tools). + """ + return CallToolResult( + content=[TextContent(type="text", text=text)], + structuredContent=envelope.model_dump(mode="json"), + ) # SEC-004 / SEC-021: Egress-Allow-List auf Code-Ebene (immutable). # Jeder ausgehende Request muss gegen diese Liste vertrauenswuerdiger @@ -165,15 +262,13 @@ def _provenance(source: str, url: str) -> dict: def _assert_safe_url(url: str) -> None: """ - Validiert eine Ziel-URL vor jedem ausgehenden Request. + Schnelle Vorpruefung vor jedem ausgehenden Request (ohne DNS): - Schutz gegen SSRF (SEC-004), DNS-Rebinding-Restrisiko (SEC-005) und - erzwingt die Egress-Allow-List (SEC-021): + * Schema muss ``https`` sein (SEC-004). + * Host muss in :data:`ALLOWED_HOSTS` stehen — Default-Deny (SEC-021). - * Schema muss ``https`` sein. - * Host muss in :data:`ALLOWED_HOSTS` stehen (Default-Deny). - * Aufgeloeste IP-Adressen duerfen nicht privat/loopback/link-local sein - (blockt u.a. 169.254.169.254, ::1, fe80::/10). + Die DNS-Aufloesung + IP-Validierung + das Pinning erfolgen in + :func:`_resolve_and_validate` bzw. :class:`_PinnedNetworkBackend` (SEC-005). """ parts = urlsplit(url) if parts.scheme != "https": @@ -184,8 +279,15 @@ def _assert_safe_url(url: str) -> None: log.warning("egress_blocked", reason="host_not_allowed", host=host) raise ToolError("Ziel-Host ist nicht in der erlaubten Egress-Liste.") + +def _resolve_and_validate(host: str, port: int) -> str: + """ + Loest ``host`` **genau einmal** auf, validiert alle zurueckgegebenen IPs gegen + die SSRF-Blocklist (privat/loopback/link-local/reserved, u.a. 169.254.169.254, + ::1, fe80::/10) und gibt die zu verwendende (gepinnte) IP zurueck (SEC-004/005). + """ try: - infos = socket.getaddrinfo(host, parts.port or 443, proto=socket.IPPROTO_TCP) + infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) except OSError as exc: raise ToolError("Host-Aufloesung fehlgeschlagen.") from exc @@ -194,6 +296,48 @@ def _assert_safe_url(url: str) -> None: if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: log.warning("egress_blocked", reason="unsafe_ip", host=host, ip=str(ip)) raise ToolError("Aufgeloeste IP-Adresse ist nicht erlaubt (SSRF-Schutz).") + return infos[0][4][0] + + +class _PinnedNetworkBackend(httpcore.AsyncNetworkBackend): + """ + SEC-005 (DNS-Rebinding-Prevention): loest den Hostnamen einmal auf, validiert + die IP und pinnt die TCP-Verbindung auf genau diese IP. TLS-SNI und + Zertifikatspruefung verwenden weiterhin den urspruenglichen Hostnamen, da + httpcore ``start_tls`` separat mit dem Origin-Host aufruft. + """ + + def __init__(self, inner: httpcore.AsyncNetworkBackend) -> None: + self._inner = inner + + async def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None): + pinned_ip = _resolve_and_validate(host, port) + return await self._inner.connect_tcp( + pinned_ip, port, timeout=timeout, + local_address=local_address, socket_options=socket_options, + ) + + async def connect_unix_socket(self, *args, **kwargs): # pragma: no cover - unused + return await self._inner.connect_unix_socket(*args, **kwargs) + + async def sleep(self, seconds: float) -> None: + await self._inner.sleep(seconds) + + +def _new_http_client() -> httpx.AsyncClient: + """ + Erzeugt einen AsyncClient, dessen Verbindungen DNS-gepinnt und SSRF-validiert + sind (SEC-004/005). Faellt defensiv auf einen ungepinnten Client zurueck, falls + sich die httpx-Internas aendern — die Allow-List (SEC-021) bleibt aktiv. + """ + transport = httpx.AsyncHTTPTransport() + pool = getattr(transport, "_pool", None) + backend = getattr(pool, "_network_backend", None) + if backend is not None: + pool._network_backend = _PinnedNetworkBackend(backend) + else: # pragma: no cover - defensive + log.warning("dns_pinning_unavailable") + return httpx.AsyncClient(timeout=HTTP_TIMEOUT, transport=transport) # ─────────────────────────── Enum ────────────────────────────────────────────── class ResponseFormat(StrEnum): @@ -214,7 +358,7 @@ async def _http_get(url: str, params: dict | None = None) -> httpx.Response: if _http_client is not None: return await _http_client.get(url, params=params) # Fallback (z.B. Direktaufruf ausserhalb des Server-Lifespans / Tests). - async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: + async with _new_http_client() as client: return await client.get(url, params=params) @@ -364,8 +508,8 @@ class RechtskontextInput(BaseModel): # ─────────────────────────── Tools ───────────────────────────────────────────── -@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)) -async def epl_sl_suche(eingabe: SLSucheInput, ctx: Context | None = None) -> str: +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), structured_output=True) +async def epl_sl_suche(eingabe: SLSucheInput, ctx: Context | None = None) -> SLSucheEnvelope: """ Suche in der Spezialitaetenliste (SL) nach kassenpflichtigen Medikamenten. @@ -384,20 +528,21 @@ async def epl_sl_suche(eingabe: SLSucheInput, ctx: Context | None = None) -> str # ARCH-003: Treffer klassifizieren; bei "none" Handlungshinweis mitgeben. match_type: MatchType = "exact" if results else "none" + envelope = SLSucheEnvelope( + source="BAG Spezialitaetenliste (SL)", + provenance=_provenance("BAG Spezialitaetenliste (SL)", SL_BASE_URL), + suchbegriff=eingabe.suchbegriff, + match_type=match_type, + count=len(results), + results=[SLTreffer(**r) for r in results], + hinweis=ergebnis.get("hinweis"), + direkt_link=ergebnis.get("direkt_link"), + fhir_status=ergebnis.get("fhir_status"), + ) + if eingabe.format == ResponseFormat.JSON: - envelope = { - "source": "BAG Spezialitaetenliste (SL)", - "provenance": _provenance("BAG Spezialitaetenliste (SL)", SL_BASE_URL), - "suchbegriff": eingabe.suchbegriff, - "match_type": match_type, - "count": len(results), - "results": results, - } - if "hinweis" in ergebnis: - envelope["hinweis"] = ergebnis["hinweis"] - envelope["direkt_link"] = ergebnis["direkt_link"] - envelope["fhir_status"] = ergebnis["fhir_status"] - return json.dumps(envelope, ensure_ascii=False, indent=2) + text = json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False, indent=2) + return _structured_result(text, envelope) # Markdown-Ausgabe md = f"## SL-Suche: \u00ab{eingabe.suchbegriff}\u00bb\n\n" @@ -415,7 +560,7 @@ async def epl_sl_suche(eingabe: SLSucheInput, ctx: Context | None = None) -> str md += f"- **{item.get('name', 'Unbekannt')}**\n" md += f"\n---\n*Quelle: BAG Spezialitaetenliste \u00b7 Lizenz: {OGD_LICENSE} \u00b7 {SL_BASE_URL}*\n" - return md + return _structured_result(md, envelope) except ToolError: raise @@ -423,8 +568,8 @@ async def epl_sl_suche(eingabe: SLSucheInput, ctx: Context | None = None) -> str raise ToolError(_handle_error(e, "SL-Suche")) from e -@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False)) -async def epl_ggsl_abfrage(eingabe: GGSLAbfrageInput, ctx: Context | None = None) -> str: +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False), structured_output=True) +async def epl_ggsl_abfrage(eingabe: GGSLAbfrageInput, ctx: Context | None = None) -> GGSLEnvelope: """ GGSL-Deckung bei Geburtsgebrechen pruefen. @@ -441,33 +586,34 @@ async def epl_ggsl_abfrage(eingabe: GGSLAbfrageInput, ctx: Context | None = None try: gg_nr = eingabe.geburtsgebrechen_nr - info = { - "geburtsgebrechen_nr": gg_nr, - "status": "Phase 1 \u2014 Statische Information", - "erklaerung": ( + envelope = GGSLEnvelope( + source="BAG GGSL", + provenance=_provenance("BAG GGSL", GGSL_INFO_URL), + geburtsgebrechen_nr=gg_nr, + status="Phase 1 \u2014 Statische Information", + erklaerung=( f"Die GGSL listet Medikamente, die bei Geburtsgebrechen Nr. {gg_nr} " "von der IV uebernommen werden. Die vollstaendige Liste ist beim BAG einsehbar." ), - "link": GGSL_INFO_URL, - "rechtsgrundlage": "IVG Art. 13 / GgV (Geburtsgebrechen-Verordnung)", - "hinweis": ( + link=GGSL_INFO_URL, + rechtsgrundlage="IVG Art. 13 / GgV (Geburtsgebrechen-Verordnung)", + hinweis=( "Fuer die aktuelle Medikamentenliste zu diesem Geburtsgebrechen " "konsultieren Sie bitte die offizielle BAG-Seite." ), - } + ) if eingabe.format == ResponseFormat.JSON: - info["source"] = "BAG GGSL" - info["provenance"] = _provenance("BAG GGSL", GGSL_INFO_URL) - return json.dumps(info, ensure_ascii=False, indent=2) + text = json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False, indent=2) + return _structured_result(text, envelope) md = f"## GGSL-Abfrage: Geburtsgebrechen Nr. {gg_nr}\n\n" - md += f"> {info['erklaerung']}\n\n" - md += f"**Offizielle Quelle:** [BAG GGSL]({info['link']})\n\n" - md += f"**Rechtsgrundlage:** {info['rechtsgrundlage']}\n\n" - md += f"**Hinweis:** {info['hinweis']}\n" + md += f"> {envelope.erklaerung}\n\n" + md += f"**Offizielle Quelle:** [BAG GGSL]({envelope.link})\n\n" + md += f"**Rechtsgrundlage:** {envelope.rechtsgrundlage}\n\n" + md += f"**Hinweis:** {envelope.hinweis}\n" md += f"\n---\n*Quelle: BAG GGSL \u00b7 Lizenz: {OGD_LICENSE} \u00b7 {GGSL_INFO_URL}*\n" - return md + return _structured_result(md, envelope) except ToolError: raise @@ -475,8 +621,8 @@ async def epl_ggsl_abfrage(eingabe: GGSLAbfrageInput, ctx: Context | None = None raise ToolError(_handle_error(e, "GGSL-Abfrage")) from e -@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False)) -async def epl_migel_suche(eingabe: MiGeLSucheInput, ctx: Context | None = None) -> str: +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False), structured_output=True) +async def epl_migel_suche(eingabe: MiGeLSucheInput, ctx: Context | None = None) -> MiGeLEnvelope: """ Suche in der Mittel- und Gegenstaendeliste (MiGeL) nach Medizinprodukten. @@ -490,35 +636,33 @@ async def epl_migel_suche(eingabe: MiGeLSucheInput, ctx: Context | None = None) """ _bind_call_context(ctx, "epl_migel_suche") try: - info = { - "suchbegriff": eingabe.suchbegriff, - "status": "Phase 1 \u2014 Kategorie-basierte Information", - "erklaerung": ( + # ARCH-003: Phase 1 liefert noch keine Live-Treffer -> match_type "none" + # mit Handlungshinweis (offizieller Link). + envelope = MiGeLEnvelope( + source="BAG MiGeL", + provenance=_provenance("BAG MiGeL", MIGEL_INFO_URL), + suchbegriff=eingabe.suchbegriff, + status="Phase 1 \u2014 Kategorie-basierte Information", + erklaerung=( f"Die MiGeL-Suche nach \u00ab{eingabe.suchbegriff}\u00bb liefert Informationen " "zu vergueteten Medizinprodukten und Hilfsmitteln." ), - "link": MIGEL_INFO_URL, - "rechtsgrundlage": "KLV Art. 20 / MiGeL-Verordnung", - "migel_integration": "MiGeL wird voraussichtlich 2026/2027 in die ePL integriert.", - } + link=MIGEL_INFO_URL, + rechtsgrundlage="KLV Art. 20 / MiGeL-Verordnung", + migel_integration="MiGeL wird voraussichtlich 2026/2027 in die ePL integriert.", + ) if eingabe.format == ResponseFormat.JSON: - # ARCH-003: Phase 1 liefert noch keine Live-Treffer -> match_type "none" - # mit Handlungshinweis (offizieller Link). - info["source"] = "BAG MiGeL" - info["provenance"] = _provenance("BAG MiGeL", MIGEL_INFO_URL) - info["match_type"] = "none" - info["count"] = 0 - info["results"] = [] - return json.dumps(info, ensure_ascii=False, indent=2) + text = json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False, indent=2) + return _structured_result(text, envelope) md = f"## MiGeL-Suche: \u00ab{eingabe.suchbegriff}\u00bb\n\n" - md += f"> {info['erklaerung']}\n\n" - md += f"**Offizielle Quelle:** [BAG MiGeL]({info['link']})\n\n" - md += f"**Rechtsgrundlage:** {info['rechtsgrundlage']}\n\n" - md += f"**ePL-Integration:** {info['migel_integration']}\n" + md += f"> {envelope.erklaerung}\n\n" + md += f"**Offizielle Quelle:** [BAG MiGeL]({envelope.link})\n\n" + md += f"**Rechtsgrundlage:** {envelope.rechtsgrundlage}\n\n" + md += f"**ePL-Integration:** {envelope.migel_integration}\n" md += f"\n---\n*Quelle: BAG MiGeL \u00b7 Lizenz: {OGD_LICENSE} \u00b7 {MIGEL_INFO_URL}*\n" - return md + return _structured_result(md, envelope) except ToolError: raise @@ -526,8 +670,8 @@ async def epl_migel_suche(eingabe: MiGeLSucheInput, ctx: Context | None = None) raise ToolError(_handle_error(e, "MiGeL-Suche")) from e -@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False)) -async def epl_gesuchseingaenge(ctx: Context | None = None) -> str: +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False), structured_output=True) +async def epl_gesuchseingaenge(ctx: Context | None = None) -> GesuchseingaengeEnvelope: """ Aktuelle Gesuchseingaenge fuer die Spezialitaetenliste abrufen. @@ -540,27 +684,29 @@ async def epl_gesuchseingaenge(ctx: Context | None = None) -> str: """ _bind_call_context(ctx, "epl_gesuchseingaenge") try: - info = { - "beschreibung": ( + envelope = GesuchseingaengeEnvelope( + source="BAG Spezialitaetenliste (SL)", + provenance=_provenance("BAG Spezialitaetenliste (SL)", SL_BASE_URL), + beschreibung=( "Die Gesuchseingaenge zeigen, welche Arzneimittel aktuell zur Aufnahme " "in die Spezialitaetenliste beantragt wurden. Diese Transparenzliste wird " "periodisch vom BAG aktualisiert." ), - "link": f"{SL_BASE_URL}/#/applications", - "direkt_link_bag": f"{BAG_DOWNLOAD_URL}/Arzneimittel/gesuchseingaenge.html", - "hinweis": ( + link=f"{SL_BASE_URL}/#/applications", + direkt_link_bag=f"{BAG_DOWNLOAD_URL}/Arzneimittel/gesuchseingaenge.html", + hinweis=( "Die vollstaendige Liste der Gesuchseingaenge ist auf sl.bag.admin.ch einsehbar. " "Die API-basierte Abfrage wird mit Phase 2 (FHIR) verfuegbar." ), - } + ) md = "## Gesuchseingaenge Spezialitaetenliste\n\n" - md += f"> {info['beschreibung']}\n\n" - md += f"**SL-Portal:** [Gesuchseingaenge ansehen]({info['link']})\n\n" - md += f"**BAG-Seite:** [Offizielle BAG-Seite]({info['direkt_link_bag']})\n\n" - md += f"**Hinweis:** {info['hinweis']}\n" + md += f"> {envelope.beschreibung}\n\n" + md += f"**SL-Portal:** [Gesuchseingaenge ansehen]({envelope.link})\n\n" + md += f"**BAG-Seite:** [Offizielle BAG-Seite]({envelope.direkt_link_bag})\n\n" + md += f"**Hinweis:** {envelope.hinweis}\n" md += f"\n---\n*Quelle: BAG Spezialitaetenliste · Lizenz: {OGD_LICENSE} · {SL_BASE_URL}*\n" - return md + return _structured_result(md, envelope) except ToolError: raise @@ -568,8 +714,8 @@ async def epl_gesuchseingaenge(ctx: Context | None = None) -> str: raise ToolError(_handle_error(e, "Gesuchseingaenge")) from e -@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False)) -async def epl_rechtskontext(eingabe: RechtskontextInput, ctx: Context | None = None) -> str: +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False), structured_output=True) +async def epl_rechtskontext(eingabe: RechtskontextInput, ctx: Context | None = None) -> RechtskontextEnvelope: """ Rechtlichen Kontext zur Kassenpflicht liefern. @@ -583,67 +729,66 @@ async def epl_rechtskontext(eingabe: RechtskontextInput, ctx: Context | None = N """ _bind_call_context(ctx, "epl_rechtskontext") try: - rechtsrahmen = { - "frage": eingabe.frage, - "gesetze": [ - { - "kuerzel": "KVG", - "titel": "Bundesgesetz ueber die Krankenversicherung", - "sr_nummer": "SR 832.10", - "fedlex": "https://www.fedlex.admin.ch/eli/cc/1995/1328_1328_1328/de", - "relevante_artikel": ["Art. 25 (Leistungen)", "Art. 32 (WZW)", "Art. 52 (SL)"], - }, - { - "kuerzel": "KLV", - "titel": "Krankenpflege-Leistungsverordnung", - "sr_nummer": "SR 832.112.31", - "fedlex": "https://www.fedlex.admin.ch/eli/cc/1995/4964_4964_4964/de", - "relevante_artikel": ["Art. 20 (MiGeL)", "Art. 30ff (SL-Aufnahme)"], - }, - { - "kuerzel": "IVG", - "titel": "Bundesgesetz ueber die Invalidenversicherung", - "sr_nummer": "SR 831.20", - "fedlex": "https://www.fedlex.admin.ch/eli/cc/1959/827_857_845/de", - "relevante_artikel": ["Art. 13 (Geburtsgebrechen)"], - }, - { - "kuerzel": "GgV", - "titel": "Verordnung ueber Geburtsgebrechen", - "sr_nummer": "SR 831.232.21", - "fedlex": "https://www.fedlex.admin.ch/eli/cc/1986/40_40_40/de", - "relevante_artikel": ["Anhang (Liste der Geburtsgebrechen)"], - }, + envelope = RechtskontextEnvelope( + source="Fedlex (Bundesrecht)", + provenance=_provenance("Fedlex (Bundesrecht)", "https://www.fedlex.admin.ch"), + frage=eingabe.frage, + gesetze=[ + Gesetz( + kuerzel="KVG", + titel="Bundesgesetz ueber die Krankenversicherung", + sr_nummer="SR 832.10", + fedlex="https://www.fedlex.admin.ch/eli/cc/1995/1328_1328_1328/de", + relevante_artikel=["Art. 25 (Leistungen)", "Art. 32 (WZW)", "Art. 52 (SL)"], + ), + Gesetz( + kuerzel="KLV", + titel="Krankenpflege-Leistungsverordnung", + sr_nummer="SR 832.112.31", + fedlex="https://www.fedlex.admin.ch/eli/cc/1995/4964_4964_4964/de", + relevante_artikel=["Art. 20 (MiGeL)", "Art. 30ff (SL-Aufnahme)"], + ), + Gesetz( + kuerzel="IVG", + titel="Bundesgesetz ueber die Invalidenversicherung", + sr_nummer="SR 831.20", + fedlex="https://www.fedlex.admin.ch/eli/cc/1959/827_857_845/de", + relevante_artikel=["Art. 13 (Geburtsgebrechen)"], + ), + Gesetz( + kuerzel="GgV", + titel="Verordnung ueber Geburtsgebrechen", + sr_nummer="SR 831.232.21", + fedlex="https://www.fedlex.admin.ch/eli/cc/1986/40_40_40/de", + relevante_artikel=["Anhang (Liste der Geburtsgebrechen)"], + ), ], - "wzw_kriterien": { + wzw_kriterien={ "wirksamkeit": "Das Arzneimittel muss wirksam sein (klinische Studien).", "zweckmaessigkeit": "Der Einsatz muss zweckmaessig sein (Nutzen-Risiko).", "wirtschaftlichkeit": "Die Kosten muessen in einem angemessenen Verhaeltnis stehen.", }, - "hinweis": "Fuer verbindliche Rechtsauskunft konsultieren Sie die offiziellen Fedlex-Quellen.", - } + hinweis="Fuer verbindliche Rechtsauskunft konsultieren Sie die offiziellen Fedlex-Quellen.", + ) if eingabe.format == ResponseFormat.JSON: - rechtsrahmen["source"] = "Fedlex (Bundesrecht)" - rechtsrahmen["provenance"] = _provenance( - "Fedlex (Bundesrecht)", "https://www.fedlex.admin.ch" - ) - return json.dumps(rechtsrahmen, ensure_ascii=False, indent=2) + text = json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False, indent=2) + return _structured_result(text, envelope) md = f"## Rechtskontext: {eingabe.frage}\n\n" md += "### Relevante Gesetze\n\n" - for g in rechtsrahmen["gesetze"]: - md += f"#### {g['kuerzel']} \u2014 {g['titel']}\n" - md += f"- **SR-Nummer:** {g['sr_nummer']}\n" - md += f"- **Fedlex:** [{g['kuerzel']} auf Fedlex]({g['fedlex']})\n" - md += f"- **Relevante Artikel:** {', '.join(g['relevante_artikel'])}\n\n" + for g in envelope.gesetze: + md += f"#### {g.kuerzel} \u2014 {g.titel}\n" + md += f"- **SR-Nummer:** {g.sr_nummer}\n" + md += f"- **Fedlex:** [{g.kuerzel} auf Fedlex]({g.fedlex})\n" + md += f"- **Relevante Artikel:** {', '.join(g.relevante_artikel)}\n\n" md += "### WZW-Kriterien (KVG Art. 32)\n\n" - for k, v in rechtsrahmen["wzw_kriterien"].items(): + for k, v in envelope.wzw_kriterien.items(): md += f"- **{k.capitalize()}:** {v}\n" - md += f"\n> **Hinweis:** {rechtsrahmen['hinweis']}\n" - return md + md += f"\n> **Hinweis:** {envelope.hinweis}\n" + return _structured_result(md, envelope) except ToolError: raise @@ -651,8 +796,8 @@ async def epl_rechtskontext(eingabe: RechtskontextInput, ctx: Context | None = N raise ToolError(_handle_error(e, "Rechtskontext")) from e -@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False)) -async def epl_server_info(ctx: Context | None = None) -> str: +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False), structured_output=True) +async def epl_server_info(ctx: Context | None = None) -> ServerInfoEnvelope: """ Serverstatus und API-Phaseninformation anzeigen. @@ -664,13 +809,15 @@ async def epl_server_info(ctx: Context | None = None) -> str: Roadmap-Stands. """ _bind_call_context(ctx, "epl_server_info") - info = { - "server": "bag-epl-mcp", - "version": "0.1.0", - "protocol_version": PROTOCOL_VERSION, - "license": OGD_LICENSE, - "phase": "Phase 1 \u2014 XML/XLSX-Downloads + SL-Website-Zugriff", - "tools": { + envelope = ServerInfoEnvelope( + source="bag-epl-mcp", + provenance=_provenance("bag-epl-mcp", "https://github.com/malkreide/bag-epl-mcp"), + server="bag-epl-mcp", + version="0.1.0", + protocol_version=PROTOCOL_VERSION, + license=OGD_LICENSE, + phase="Phase 1 \u2014 XML/XLSX-Downloads + SL-Website-Zugriff", + tools={ "epl_sl_suche": "Medikamentensuche in der Spezialitaetenliste (SL)", "epl_ggsl_abfrage": "GGSL-Deckung bei Geburtsgebrechen pruefen", "epl_migel_suche": "Medizinprodukte in der MiGeL suchen", @@ -678,29 +825,29 @@ async def epl_server_info(ctx: Context | None = None) -> str: "epl_rechtskontext": "Rechtliche Grundlagen zur Kassenpflicht", "epl_server_info": "Serverstatus (dieses Tool)", }, - "phasen": { + phasen={ "phase_1": "XML/XLSX-Downloads + SL-Website (aktuell)", "phase_2": "FHIR/IDMP-API des BAG (~2025/2026)", "phase_3": "MiGeL + AL via ePL-FHIR (2026/2027)", }, - "datenquellen": { + datenquellen={ "sl": f"{SL_BASE_URL} \u2014 Spezialitaetenliste", "ggsl": GGSL_INFO_URL, "migel": MIGEL_INFO_URL, }, - } + ) md = "## BAG ePL MCP Server \u2014 Status\n\n" - md += f"**Version:** {info['version']}\n\n" - md += f"**MCP-Protokoll:** {info['protocol_version']}\n\n" - md += f"**Aktuelle Phase:** {info['phase']}\n\n" + md += f"**Version:** {envelope.version}\n\n" + md += f"**MCP-Protokoll:** {envelope.protocol_version}\n\n" + md += f"**Aktuelle Phase:** {envelope.phase}\n\n" md += "### Verfuegbare Tools\n\n" - for tool, desc in info["tools"].items(): + for tool, desc in envelope.tools.items(): md += f"| `{tool}` | {desc} |\n" md += "\n### Phasenplan\n\n" - for phase, desc in info["phasen"].items(): + for phase, desc in envelope.phasen.items(): md += f"- **{phase}:** {desc}\n" - return md + return _structured_result(md, envelope) # ─────────────────────────── Resources ───────────────────────────────────────── @@ -802,13 +949,18 @@ async def _healthz(_request: Request) -> PlainTextResponse: def _init_otel(app) -> None: """ - OBS-006: optionales OpenTelemetry-Tracing (opt-in via ``MCP_OTEL_ENABLED=1``). + OBS-006: OpenTelemetry-Tracing, standardmaessig aktiv (mit ``MCP_OTEL_ENABLED=0`` + abschaltbar). Auto-Instrumentierung der ASGI-App und des HTTP-Clients erzeugt Spans pro Request bzw. ausgehendem Backend-Call. Exporter/Endpoint werden ueber die Standard-``OTEL_*``-Umgebungsvariablen konfiguriert. Keine sensiblen Daten (PII/Credentials) in Span-Attributen — die Auto-Instrumentierung loggt nur Methode/Status/URL. + + Ist das ``[otel]``-Extra nicht installiert, ist dies ein stiller No-op + (Base-Installs bleiben unveraendert), damit „aktiv per Default" nicht zur + harten Abhaengigkeit wird. """ try: from opentelemetry import trace @@ -819,7 +971,7 @@ def _init_otel(app) -> None: from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor except ImportError: - log.warning("otel_unavailable", hint="install the '[otel]' extra") + log.debug("otel_unavailable", hint="install the '[otel]' extra to enable tracing") return # TracerProvider + OTLP-Exporter; Endpoint via OTEL_EXPORTER_OTLP_ENDPOINT. provider = TracerProvider(resource=Resource.create({"service.name": "bag-epl-mcp"})) diff --git a/tests/test_live.py b/tests/test_live.py index ca1a234..ce42c42 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -33,6 +33,15 @@ async def test_live_sl_suche(): assert "direkt_link" in result or "results" in result +async def test_live_dns_pinning_tls_ok(): + # SEC-005: echter Request ueber den gepinnten Transport -> TLS muss gegen den + # Hostnamen valide bleiben (kein SSL-Fehler), obwohl auf die IP verbunden wird. + from bag_epl_mcp.server import _new_http_client + async with _new_http_client() as client: + resp = await client.get("https://www.fedlex.admin.ch/") + assert resp.status_code is not None + + async def test_live_sl_suche_tool(): result = await epl_sl_suche(SLSucheInput(suchbegriff="Aspirin")) assert "SL-Suche" in result diff --git a/tests/test_unit.py b/tests/test_unit.py index c8aa465..f68efb0 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -48,6 +48,16 @@ def _inner(host, port, *args, **kwargs): return _inner +def _text(result): + """Menschenlesbarer content-Block einer Tool-Antwort (SDK-002 CallToolResult).""" + return result.content[0].text + + +def _struct(result): + """structuredContent einer Tool-Antwort (SDK-002).""" + return result.structuredContent + + # ─────────────────────────── Hilfsfunktionen ─────────────────────────────────── class TestPaginateHelper: @@ -158,17 +168,22 @@ async def test_sl_suche_fallback(self): async def test_sl_suche_tool_markdown(self): respx.get(f"{SL_API_URL}/search").mock(side_effect=httpx.ConnectError("no api")) result = await epl_sl_suche(SLSucheInput(suchbegriff="Methylphenidat")) - assert "SL-Suche" in result and "Methylphenidat" in result + text = _text(result) + assert "SL-Suche" in text and "Methylphenidat" in text # CH-004: Quelle/Lizenz im Markdown-Footer - assert OGD_LICENSE in result and "sl.bag.admin.ch" in result + assert OGD_LICENSE in text and "sl.bag.admin.ch" in text + # SDK-002: getyptes structuredContent immer mitgeliefert + assert _struct(result)["match_type"] == "none" @respx.mock @pytest.mark.asyncio async def test_sl_suche_tool_json_envelope(self): respx.get(f"{SL_API_URL}/search").mock(side_effect=httpx.ConnectError("no api")) result = await epl_sl_suche(SLSucheInput(suchbegriff="Aspirin", format=ResponseFormat.JSON)) - data = json.loads(result) - # SDK-002 Envelope + ARCH-003 match_type + CH-004 provenance/license + # SDK-002: content-Text ist JSON, structuredContent das getypte Envelope + data = json.loads(_text(result)) + assert data == _struct(result) + # ARCH-003 match_type + CH-004 provenance/license assert data["match_type"] == "none" assert data["count"] == 0 and data["results"] == [] assert data["provenance"]["license"] == OGD_LICENSE @@ -183,8 +198,9 @@ async def test_sl_suche_api_success_match_exact(self): result = await _sl_website_suche("Aspirin") assert result["results"][0]["name"] == "Aspirin Cardio" # Tool klassifiziert echte Treffer als "exact" - tool_json = await epl_sl_suche(SLSucheInput(suchbegriff="Aspirin", format=ResponseFormat.JSON)) - assert json.loads(tool_json)["match_type"] == "exact" + tool = await epl_sl_suche(SLSucheInput(suchbegriff="Aspirin", format=ResponseFormat.JSON)) + assert _struct(tool)["match_type"] == "exact" + assert _struct(tool)["results"][0]["name"] == "Aspirin Cardio" # ─────────────────────────── GGSL / MiGeL / Gesuche / Recht ──────────────────── @@ -193,12 +209,15 @@ class TestGGSL: @pytest.mark.asyncio async def test_ggsl_markdown(self): result = await epl_ggsl_abfrage(GGSLAbfrageInput(geburtsgebrechen_nr="313")) - assert "313" in result and "IVG" in result and OGD_LICENSE in result + text = _text(result) + assert "313" in text and "IVG" in text and OGD_LICENSE in text + assert _struct(result)["geburtsgebrechen_nr"] == "313" @pytest.mark.asyncio async def test_ggsl_json(self): result = await epl_ggsl_abfrage(GGSLAbfrageInput(geburtsgebrechen_nr="404", format=ResponseFormat.JSON)) - data = json.loads(result) + data = json.loads(_text(result)) + assert data == _struct(result) assert data["geburtsgebrechen_nr"] == "404" and "rechtsgrundlage" in data assert data["provenance"]["license"] == OGD_LICENSE @@ -207,12 +226,15 @@ class TestMiGeL: @pytest.mark.asyncio async def test_migel_markdown(self): result = await epl_migel_suche(MiGeLSucheInput(suchbegriff="Rollstuhl")) - assert "Rollstuhl" in result and "KLV" in result + text = _text(result) + assert "Rollstuhl" in text and "KLV" in text + assert _struct(result)["suchbegriff"] == "Rollstuhl" @pytest.mark.asyncio async def test_migel_json_envelope(self): result = await epl_migel_suche(MiGeLSucheInput(suchbegriff="Hoergeraet", format=ResponseFormat.JSON)) - data = json.loads(result) + data = json.loads(_text(result)) + assert data == _struct(result) assert data["suchbegriff"] == "Hoergeraet" assert data["match_type"] == "none" and data["results"] == [] assert data["provenance"]["source"] == "BAG MiGeL" @@ -222,19 +244,23 @@ class TestGesuchseingaenge: @pytest.mark.asyncio async def test_gesuchseingaenge(self): result = await epl_gesuchseingaenge() - assert "Gesuchseingaenge" in result and "sl.bag.admin.ch" in result + assert "Gesuchseingaenge" in _text(result) and "sl.bag.admin.ch" in _text(result) + assert _struct(result)["link"].startswith("https://sl.bag.admin.ch") class TestRechtskontext: @pytest.mark.asyncio async def test_rechtskontext_markdown(self): result = await epl_rechtskontext(RechtskontextInput(frage="Welche Gesetze regeln die SL?")) - assert "KVG" in result and "KLV" in result and "WZW" in result + text = _text(result) + assert "KVG" in text and "KLV" in text and "WZW" in text + assert len(_struct(result)["gesetze"]) >= 3 @pytest.mark.asyncio async def test_rechtskontext_json(self): result = await epl_rechtskontext(RechtskontextInput(frage="Rechtsgrundlage SL", format=ResponseFormat.JSON)) - data = json.loads(result) + data = json.loads(_text(result)) + assert data == _struct(result) assert len(data["gesetze"]) >= 3 and "wzw_kriterien" in data assert "provenance" in data @@ -243,9 +269,13 @@ class TestServerInfo: @pytest.mark.asyncio async def test_server_info(self): result = await epl_server_info() - assert "BAG ePL MCP Server" in result and "Phase 1" in result + text = _text(result) + assert "BAG ePL MCP Server" in text and "Phase 1" in text # ARCH-012: Protokoll-Version dokumentiert - assert PROTOCOL_VERSION in result + assert PROTOCOL_VERSION in text + # SDK-002: getyptes structuredContent + assert _struct(result)["protocol_version"] == PROTOCOL_VERSION + assert "epl_sl_suche" in _struct(result)["tools"] # ─────────────────────────── Egress-Guard (SEC-004/005/021) ─────────────────── @@ -263,14 +293,21 @@ def test_unerlaubter_host_abgelehnt(self): _assert_safe_url("https://evil.example.com/steal") def test_private_ip_abgelehnt(self, monkeypatch): + from bag_epl_mcp.server import _resolve_and_validate monkeypatch.setattr("bag_epl_mcp.server.socket.getaddrinfo", _fake_getaddrinfo("127.0.0.1")) with pytest.raises(ToolError): - _assert_safe_url("https://sl.bag.admin.ch/api/search") + _resolve_and_validate("sl.bag.admin.ch", 443) def test_metadata_ip_abgelehnt(self, monkeypatch): + from bag_epl_mcp.server import _resolve_and_validate monkeypatch.setattr("bag_epl_mcp.server.socket.getaddrinfo", _fake_getaddrinfo("169.254.169.254")) with pytest.raises(ToolError): - _assert_safe_url("https://www.bag.admin.ch/x") + _resolve_and_validate("www.bag.admin.ch", 443) + + def test_resolve_and_validate_gibt_pinned_ip(self, monkeypatch): + from bag_epl_mcp.server import _resolve_and_validate + monkeypatch.setattr("bag_epl_mcp.server.socket.getaddrinfo", _fake_getaddrinfo("93.184.216.34")) + assert _resolve_and_validate("sl.bag.admin.ch", 443) == "93.184.216.34" @pytest.mark.asyncio async def test_http_get_ruft_guard_auf(self): @@ -282,13 +319,62 @@ def test_allowed_hosts_nur_admin_ch(self): assert all(h.endswith(".admin.ch") for h in ALLOWED_HOSTS) +# ─────────────────────────── DNS-Pinning (SEC-005) ──────────────────────────── + +class TestDnsPinning: + """Die TCP-Verbindung wird auf die validierte IP gepinnt (SEC-005).""" + + @pytest.mark.asyncio + async def test_pinned_backend_waehlt_validierte_ip(self, monkeypatch): + from bag_epl_mcp.server import _PinnedNetworkBackend + monkeypatch.setattr("bag_epl_mcp.server.socket.getaddrinfo", _fake_getaddrinfo("93.184.216.34")) + dialed = {} + + class _Inner: + async def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None): + dialed["host"] = host + dialed["port"] = port + return object() + + backend = _PinnedNetworkBackend(_Inner()) + await backend.connect_tcp("sl.bag.admin.ch", 443) + # Es wird die aufgeloeste IP gewaehlt, nicht der Hostname. + assert dialed["host"] == "93.184.216.34" and dialed["port"] == 443 + + @pytest.mark.asyncio + async def test_pinned_backend_blockt_private_ip(self, monkeypatch): + from bag_epl_mcp.server import _PinnedNetworkBackend + monkeypatch.setattr("bag_epl_mcp.server.socket.getaddrinfo", _fake_getaddrinfo("10.0.0.5")) + + class _Inner: + async def connect_tcp(self, *a, **k): # pragma: no cover - darf nicht erreicht werden + raise AssertionError("connect_tcp duerfte nicht aufgerufen werden") + + with pytest.raises(ToolError): + await _PinnedNetworkBackend(_Inner()).connect_tcp("sl.bag.admin.ch", 443) + + def test_new_http_client_ist_gepinnt(self): + from bag_epl_mcp.server import _new_http_client, _PinnedNetworkBackend + client = _new_http_client() + try: + backend = client._transport._pool._network_backend + assert isinstance(backend, _PinnedNetworkBackend) + finally: + pass + + # ─────────────────────────── Settings & Transport ───────────────────────────── class TestSettings: def test_defaults_sicher(self): s = ServerSettings() assert s.transport == "stdio" and s.host == "127.0.0.1" - assert s.otel_enabled is False + # OBS-006: Tracing standardmaessig aktiv (stiller No-op ohne [otel]-Extra). + assert s.otel_enabled is True + + def test_otel_abschaltbar(self, monkeypatch): + monkeypatch.setenv("MCP_OTEL_ENABLED", "false") + assert ServerSettings().otel_enabled is False def test_env_override(self, monkeypatch): monkeypatch.setenv("MCP_TRANSPORT", "streamable-http") @@ -389,8 +475,15 @@ def test_bind_call_context_setzt_felder(self): @pytest.mark.asyncio async def test_tools_akzeptieren_ctx_injection(self): # FastMCP injiziert Context; defensiver Zugriff -> kein Crash. + from mcp.types import CallToolResult res = await mcp.call_tool("epl_server_info", {}) - text = res[0][0].text if isinstance(res, tuple) else res[0].text + if isinstance(res, CallToolResult): # SDK-002: content + structuredContent + text = res.content[0].text + assert res.structuredContent["protocol_version"] == PROTOCOL_VERSION + elif isinstance(res, tuple): + text = res[0][0].text + else: + text = res[0].text assert "BAG ePL MCP Server" in text def test_alle_tools_haben_ctx_param(self):