> πŸ‡¨πŸ‡­ **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)** # SECO Labor Market MCP Server ![Version](https://img.shields.io/badge/version-0.4.0-blue) [![CI](https://github.com/malkreide/seco-labor-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/malkreide/seco-labor-mcp/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/seco-labor-mcp)](https://pypi.org/project/seco-labor-mcp/) [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/) [![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-purple)](https://modelcontextprotocol.io/) [![No Auth Required](https://img.shields.io/badge/auth-none%20required-brightgreen)](https://github.com/malkreide/seco-labor-mcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) 🌐 **English** | **[Deutsch](README.de.md)** An MCP (Model Context Protocol) server for Swiss labor market data from **SECO** (Staatssekretariat fΓΌr Wirtschaft) and **AMSTAT** via opendata.swiss.

Demo: Claude queries youth unemployment via seco-labor-mcp tool call

--- ## Overview This server connects AI models to Swiss labor market statistics β€” unemployment rates, job seekers, open positions, youth unemployment, and occupational breakdowns β€” all without requiring an API key. **Primary audiences:** - 🏫 **Schulamt / Education planning** β€” youth unemployment, vocational guidance data - πŸ“Š **Research & analysis** β€” labor market trends, cantonal comparisons - πŸ€– **AI agents** β€” automated labor market monitoring and reporting **Anchor query:** *"Welche Berufsgruppen haben im Kanton ZΓΌrich die hΓΆchste Jugendarbeitslosigkeit, und welche Lehrberufe unterliegen der Stellenmeldepflicht?"* [β†’ More use cases by audience β†’](EXAMPLES.md) --- ## Data Sources (Phase 1 β€” No Auth Required) | Source | Description | Status | |--------|-------------|--------| | [opendata.swiss](https://opendata.swiss/de/dataset) | CKAN catalogue; the pinned BFS table `T3.3.0.1` carries the SECO annual series | βœ… Live | | [arbeit.swiss](https://www.arbeit.swiss) | Monthly press reports (PDF, structured URL pattern) | βœ… Live | | [amstat.ch](https://www.amstat.ch) | AMSTAT reference portal | ⚠️ JavaScript SPA, no public REST API | | [unfallstatistik.ch](https://www.unfallstatistik.ch) | Unfallstatistik UVG (SSUV/KSUV c/o Suva) β€” occupational accidents and diseases | ⚠️ PDF only, no API (see below) | --- ## Architecture ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ seco-labor-mcp β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ FastMCP β”‚ β”‚ 9 MCP Tools β”‚ β”‚ β”‚ β”‚ Server │◄──►│ seco_search_datasets β”‚ β”‚ β”‚ β”‚ (stdio / β”‚ β”‚ seco_get_dataset β”‚ β”‚ β”‚ β”‚ HTTP) β”‚ β”‚ seco_get_unemployment_* β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ seco_get_youth_* β”‚ β”‚ β”‚ β”‚ β”‚ seco_get_job_seekers β”‚ β”‚ β”‚ β–Ό β”‚ seco_get_open_positions β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ seco_get_monthly_url β”‚ β”‚ β”‚ β”‚ httpx β”‚ β”‚ seco_list_cantons β”‚ β”‚ β”‚ β”‚ async β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ opendata.swiss CKAN API β”‚ β”‚ https://opendata.swiss/api/3/ β”‚ β”‚ action/package_search β”‚ β”‚ action/package_show β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ SECO Data Resources β”‚ β”‚ CSV / XLSX / PDF Downloads β”‚ β”‚ (monthly labor market data) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` --- ## Where the figures come from β€” and what is missing **SECO is no longer a publisher on opendata.swiss.** Verified 2026-08-14: `organization_show` returns 404, and none of the 176 entries in `organization_list` is SECO. Until then the server filtered every search on that organisation and therefore returned **nothing** β€” a name lookup that misses looks exactly like an empty search. The registered unemployed and job seekers are still SECO's figures: the **BFS publishes them** in table `T3.3.0.1` and names SECO in the footer. The server reads that table through a **pinned dataset id** (`sources.py`), checked against the live source by a live test. | Series | 2000 | 2025 | |---|---|---| | Registered job seekers (SECO) | 124.6 | 214.1 | | Registered unemployed (SECO) | 72.0 | 133.7 | | ILO unemployed (BFS) | 126.5 | 248.5 | *thousands, annual average* The three series do **not** measure the same thing: in 2000 the ILO figure is 1.76Γ— the registered one. The server reports them separately and labelled, and never converts one into the other. ### The cantonal layer: four cantons, four schemas There is no national monthly series β€” but **four cantons publish their own RAV figures**, each in its own portal with its own column names. For those, `seco_get_unemployment_overview(canton=…)` returns real values: | Canton | Granularity | from | Level | Note | |---|---|---|---|---| | **TG** | monthly | 2016-01 | canton | only series **by age class** β†’ youth unemployment as a count | | **FR** | monthly | 2004-01 | canton **and Switzerland** | carries the national monthly figure as a comparison row | | **ZG** | monthly | 1993-01 | canton | youth unemployment only as a **rate**, not a count | | **ZH** | **annual** | 1991 | **municipality** | no monthly values; districts and regions sit in the same column as municipalities and are separated out | **The other 22 cantons get a named refusal** β€” no figure from another canton and no national aggregate. Partial coverage that feels complete is worse than none. The four series are **not comparable with each other** and do not add up to a Swiss figure: different time axes, different geographic levels, and in ZG's case a rate rather than a count. **Still not available:** unemployment by occupational group, open positions as a national series, and youth unemployment for Switzerland or for 24 of the 26 cantons. The affected tools say so and return **no** substitute figure. These values exist interactively on [amstat.ch](https://www.amstat.ch/v2/amstat_de.html), which offers no interface a server could call. --- ## Tools | Tool | Description | Key Use Case | |------|-------------|--------------| | `seco_search_datasets` | Search labour-market datasets on opendata.swiss (publisher shown per hit) | Discovery | | `seco_get_dataset` | Full metadata + download links for a dataset | Data access | | `seco_get_unemployment_overview` | Registered unemployed: national annual, cantonal for TG/FR/ZG/ZH | Labor market overview | | `seco_get_youth_unemployment` | Youth unemployment (15–24) β€” **TG** (count) and **ZG** (rate) only | πŸŽ“ Berufswahlberatung | | `seco_get_job_seekers` | Registered job seekers, national, annual series from 2000 | Training demand | | `seco_get_open_positions` | Open positions β€” **no national series available** | Sector analysis | | `seco_get_unemployment_by_occupation` | Breakdown by Berufshauptgruppe β€” **no machine-readable source** | πŸŽ“ Vocational guidance | | `seco_get_monthly_report_url` | Generate/verify PDF report URL | Source access | | `seco_list_cantons` | All 26 canton codes and names | Utility | | `seco_get_uvg_overview` | UVG key figures on occupational accidents and diseases | Risk overview | | `seco_get_uvg_by_branch` | Results per NOGA 2008 economic branch | πŸŽ“ Vocational guidance | | `seco_get_uvg_trends` | Ten-year accident time series per branch | Trend analysis | 12 of a maximum of 15 tools. --- ## Unfallstatistik UVG (SSUV) The three `seco_get_uvg_*` tools cover the risk side of the same labour market the unemployment tools describe: how many occupational accidents and diseases occur per branch, and how that develops over ten years. **The publisher is not SECO.** The Unfallstatistik UVG is issued by the Koordinationsgruppe KSUV and the Sammelstelle SSUV c/o Suva, Lucerne. The `seco_` prefix addresses this server, not the source; every response names the actual publisher in its `source` field. ### Architecture decision: C (dump-first) Verified live on 2026-08-05, full write-up in [`PROBE_REPORT_UVG.md`](PROBE_REPORT_UVG.md). The source has **no API**. A link scan across every data page returned 165 PDFs and zero files with `.csv`, `.xlsx` or `.json`. opendata.swiss does not list the source at all (`count=0` for six of seven search terms), and the BFS dam-api silently ignores its filter parameters. What remains is machine-readable in practice but not by design: | Access | Format | Refresh | |---|---|---| | `schluesselzahlen_d.htm` | HTML table, 5 years, Switzerland-wide | annually | | `Ts{YY}.pdf` | annual edition, tables 1.2 and 2.4 by NOGA | annually, June | | `WirtKl_{BUV\|NBUV}_{NN}.pdf` | ten-year series per NOGA division | annually, January | PDFs are cached for 24 h and fetched with 2s/4s/8s backoff. ### What every response tells you - `source_freshness.data_year` β€” the **data** year, not the edition year. The 2026 edition reports 2024; that two-year lag is stated, not buried. - `totals_check` β€” parsed rows are summed and compared against the total printed in the same publication. A broken layout shows up here instead of becoming a plausible wrong number. - `significant` β€” the source marks statistically significant year-on-year changes with an asterisk. That flag is preserved per data point, so a change is only reported as significant where the source says so. --- ## Installation ### Claude Desktop (stdio) Add to `claude_desktop_config.json`: ```json { "mcpServers": { "seco-labor": { "command": "uvx", "args": ["seco-labor-mcp"] } } } ``` ### Cloud / HTTP ```bash pip install seco-labor-mcp MCP_TRANSPORT=http PORT=8000 seco-labor-mcp ``` `http` is the transport to use. It is the only one that can carry the modern protocol era: measured against this server object, `http` negotiates **`2026-07-28`** while `sse` caps every client at **`2025-11-25`**, even a client that offers the modern era. `sse` and `streamable-http` remain accepted for existing deployments β€” `tests/test_transport_aera.py` runs both and records which era each one actually yields. The HTTP server binds to **`127.0.0.1` (loopback) by default** to prevent NeighborJack on shared networks. For container deployments where you actually need to accept traffic from outside the container, set `HOST=0.0.0.0` explicitly β€” ideally in your Dockerfile / orchestrator config, and only behind an upstream proxy or firewall: ```bash HOST=0.0.0.0 MCP_TRANSPORT=http PORT=8000 seco-labor-mcp # container only ``` An unknown `MCP_TRANSPORT` value now exits with an error. It used to fall back to stdio silently, so a typo produced a server that simply never appeared on the expected port. ### Development ```bash git clone https://github.com/malkreide/seco-labor-mcp.git cd seco-labor-mcp pip install -e ".[dev]" pytest tests/ -m "not live" -v ``` --- ## Usage Examples ### Search for youth unemployment data ``` Tool: seco_search_datasets Input: { "query": "Jugendarbeitslosigkeit Alter", "limit": 5 } ``` ### Get cantonal unemployment for ZΓΌrich ``` Tool: seco_get_unemployment_overview Input: { "canton": "ZH", "response_format": "markdown" } ``` ### Get monthly report URL ``` Tool: seco_get_monthly_report_url Input: { "year": 2026, "month": 2, "language": "de" } ``` --- ## Key Concepts ### Arbeitslose vs. Stellensuchende > **EselsbrΓΌcke**: Arbeitslose βŠ‚ Stellensuchende β€” Arbeitslose sind eine Teilmenge. | Term | Definition | Dec 2025 | |------|-----------|----------| | Arbeitslose | RAV-registered, immediately available | ~149'000 (3.2%) | | Stellensuchende | All RAV-registered (incl. training programs) | ~233'900 | ### Youth Unemployment Seasonality - **July/August**: Sharp increase (school leavers without placements) - **September/October**: Decline (apprenticeship starts) - The residual that remains after the autumn decline signals structural need for bridge programs (BrΓΌckenangebote) ### Stellenmeldepflicht (since 2020) Occupations with β‰₯5% unemployment rate must be reported to the RAV before posting publicly. The list changes annually. This is directly relevant for vocational counseling β€” these professions have highest availability for Swiss job seekers. --- ## Portfolio Synergies | Server | Synergy | |--------|---------| | `swiss-statistics-mcp` | BFS population/employment data for deeper context | | `zurich-opendata-mcp` | City of Zurich-level education and social data | | `swiss-snb-mcp` | Economic context (GDP, wages) for labor market interpretation | | `fedlex-mcp` | ALV (Arbeitslosenversicherung) legislative framework | --- ## Known Limitations - `amstat.arbeit.swiss` has no public REST API (JavaScript SPA) β†’ workaround via CKAN - Occupational/sectoral detail requires CSV download from SECO resources - Monthly press report URL patterns may vary for older reports - Cantonal sub-municipal data not available at this level - UVG figures come from PDF parsing β€” the layout was stable across the 2025 and 2026 editions, but a redesign can break it. The `totals_check` in every response is what makes such a break visible rather than silent. - UVG data lags roughly two years (the 2026 edition reports 2024) - UVG branch detail follows NOGA 2008 and groups some divisions (`41 – 42`, `77, 79 – 82`); there is no cantonal breakdown at this level - Detailed UVG data beyond the publications sits behind the SSUV closed user group and is out of scope for this no-auth server **Phase 2 roadmap:** - Automatic CSV caching with 24h TTL - Direct XLSX parsing for cantonal breakdowns - Integration with `zh-education-mcp` for Schulamt-specific correlations --- ## Data License Two different licences apply β€” the code of this server is MIT either way, but the data is not covered by it. **SECO / AMSTAT data** published on opendata.swiss is under **Creative Commons CCZero** (public domain). Source: Staatssekretariat fΓΌr Wirtschaft (SECO) β€” [seco.admin.ch](https://www.seco.admin.ch) **Unfallstatistik UVG data** is **not** openly licensed. The publication states: > Β«Abdruck – ausser fΓΌr kommerzielle Nutzung – mit Quellenangabe gestattet.Β» > (Reproduction permitted, except for commercial use, with attribution.) That is a non-commercial restriction with an attribution requirement. It belongs to KSUV/SSUV and cannot be lifted by this repository's MIT licence: the MIT terms cover the code, not the figures the code retrieves. **If you use this server commercially, the UVG tools are not covered** β€” clarify directly with the Sammelstelle (`unfallstatistik@suva.ch`). Every UVG response repeats this restriction in its `source` field, because a README is not passed to the model. --- ## Safety & Limits | Aspect | Details | |--------|---------| | **Access** | Read-only (`readOnlyHint: true`) β€” the server cannot modify or delete any data | | **Personal data** | No personal data β€” all sources are aggregated, anonymous public statistics | | **Rate limits** | No enforced external limits; server caps queries at 20 results by default; 30 s HTTP timeout | | **Authentication** | No API keys required β€” opendata.swiss and arbeit.swiss are publicly accessible | | **Licenses** | SECO data under [Creative Commons CCZero](https://creativecommons.org/publicdomain/zero/1.0/) (public domain) | | **Terms of Service** | Subject to ToS of: [opendata.swiss](https://opendata.swiss/de/terms-of-use), [SECO](https://www.seco.admin.ch), [arbeit.swiss](https://www.arbeit.swiss) | | **GDPR / DSG** | Fully compliant β€” no personal data transmitted or stored; all data is official public statistics | --- ## MCP Protocol Version This server serves **two protocol eras** over the same server object, on fastmcp 4.x / `mcp` 2.x: | Era | Revision | Shape | |-----|----------|-------| | Modern | **`2026-07-28`** | no handshake β€” `server/discover`, one self-contained envelope per request | | Handshake | **`2025-11-25`** | `initialize`, then a stateful session | A client that offers the modern era gets it; a client that only knows the handshake era still gets served. Both are pinned separately in `tests/test_protokoll_aeren.py`, and both are *measured* β€” the test negotiates a real connection against this server object rather than comparing two constants. Pinning only `LATEST_PROTOCOL_VERSION` would not be enough: in `mcp` 2.x that name is an alias for the *modern* era, so it would leave the handshake ceiling free to move β€” and that ceiling is what most clients in the field actually speak. Until 0.4.0 this server ran fastmcp 3.x, which pins `mcp` 1.x. There `2025-11-25` is the highest revision the SDK knows at all, so `2026-07-28` was not partially supported β€” it was absent. The test that used to guard the one-era state now guards its opposite: it fails if a downgrade takes the modern era away again. Note for anything reading server metadata: on a modern connection there is no `InitializeResult`. Use the era-neutral `protocol_version` / `server_info` instead of `initialize_result`. --- ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. --- ## Security See [SECURITY.md](SECURITY.md) for the security posture and how to report a vulnerability. --- ## License Released under the [MIT License](LICENSE) β€” Copyright Β© 2026 Hayal Oezkan. --- ## Author **Hayal Oezkan** Β· [github.com/malkreide](https://github.com/malkreide) ## Installation Run via [`uv`](https://docs.astral.sh/uv/)'s `uvx` β€” no clone or manual install needed. Add to your MCP client config (`mcpServers` for Claude Desktop, Cursor and Windsurf; use a top-level `servers` key for VS Code in `.vscode/mcp.json`): ```json { "mcpServers": { "seco-labor-mcp": { "command": "uvx", "args": [ "seco-labor-mcp" ] } } } ```