关于 swiss-procurement-mcp
swiss-procurement-mcp 是只读 MCP 服务,用于查询瑞士官方 simap.ch 公共采购 API。它面向采购研究工作流,提供全国招标与中标数据、代码查询和公告详情。
主要功能
- 01按州、日期和文本搜索招标
- 02获取采购记录详情
- 03搜索已授予的合同
- 04查看公告历史
- 05查询 CPV 和建筑代码
- 06查找公共采购机构
适用场景
- 01查找指定州和日期范围内的招标
- 02查看招标详情及采购机构
- 03追踪项目从招标到授标的过程
- 04识别 CPV 或瑞士建筑代码
MCP 相关
向 MCP 客户端提供只读的 simap.ch 采购搜索、详情及参考数据工具。服务查询公开 API,并支持 stdio 和基于 HTTP 的传输方式。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
瑞士公共数据 MCP 工具组合的一部分(Swiss Public Data MCP Portfolio)——连接 AI Agent 与瑞士公共及开放数据的开源 MCP 服务。
这是一个个人项目,独立于任何雇主或机构,不代表任何主管机关的官方立场。
swiss-procurement-mcp
用于瑞士公共采购的 MCP 服务——只读访问官方 simap.ch API,覆盖所有州和联邦,日内更新。
🎯 演示查询
«苏黎世市在 2026 年发布了哪些校舍建设招标?这些招标涉及哪些 BKP 建筑类别?采购机构有哪些?»
一次 search_procurements_detailed(query="Schulhaus", canton="ZH", published_from="2026-01-01")
调用即可返回符合条件的主要招标,并附上 BKP 建筑代码和
采购机构信息——一次调用即可将采购信息与校舍规划关联起来
(也可搭配 search_construction_codes 查询类别)。
演示
为什么开发这个服务
瑞士公共采购信息发布在 simap.ch 上。该平台的网页界面可以手动搜索,但
amtsblatt-mcp
服务只能覆盖仍将招标信息镜像到 Amtsblattportal 的三个州(AR、BS、TI),
苏黎世等州则不在其中。
simap 弥补了这一缺口:它提供有文档说明的 OpenAPI 3 只读 API(v1.5.1),
其搜索和详情端点标记为 security: None,可无需身份验证调用。本服务正是对这些只读端点的封装。
记忆口诀: 网页界面是正门;API 是装卸区。去探探装卸区。
架构决策
架构 A(仅实时 API,短时缓存)。
- 公共搜索、详情和参考数据端点无需身份验证,且已确认可实时正常工作(2026-07-26)。
- 发布信息日内会变化,因此缓存 TTL 有意设得较短(30 分钟)。
- 约 200 个写入、
my/和受 OIDC 保护的端点(发布招标、提交报价)均不在范围内——本服务绝不写入数据。
每条响应都带有 source 和 provenance(live_api / cached /
degraded)。上游故障时会返回 degraded 封装,不会静默返回空列表。
实时探测结果(2026-07-26)
| 端点 | 身份验证 | 结果 |
|---|---|---|
/publications/v2/project/project-search | 无 | 20 条结果,可按州筛选,当日数据 |
/publications/v1/.../publication-details/... | 无 | 完整记录:标准、截止日期、代码 |
/publications/v1/publication/{id}/past-publications | 无 | 项目生命周期 |
/codes/v1/cpv/search | 无 | CPV 全文搜索 |
/codes/v1/{bkp,npk,ebkp-h,ebkp-t,oag,cpc}/search | 无 | 瑞士建筑代码 |
/procoffices/v1/po/public | 无 | 约 1 MB 的机构列表(客户端筛选) |
/cantons/v1、/countries/v1 | 无 | 参考数据 |
已知发现
- 主机不对,结论也不对。 读取 API 位于
www.simap.ch/api下。simap.ch/deWeb UI 是独立的 SSR 应用,不会公开这些接口 — 对 UI 的探测导致了先前“没有 API”的错误结论。 - 项目搜索必须提供
lang。 省略该参数会返回 HTTP 400 (errorCodeE0025),而不是空结果。客户端会注入默认值。 - Award 并非“award”。
newestPubTypes=award会返回 HTTP 400。Award 按流程拆分为:award_tender、award_study_contract、award_competition、direct_award。search_awards工具会查询这四种类型。 - Canton id 不带前缀。 使用
ZH,而不是CH-ZH。传入 ISO 行政区划代码 会静默地匹配不到任何结果;此服务会明确报错。 - 必须使用会话 Cookie。 第一个请求会设置该 Cookie;持久化的 HTTP 客户端会自动处理。
工具
| 工具 | 用途 |
|---|---|
search_procurements | 按州、CPV、流程类型、日期和文本搜索项目 |
search_procurements_detailed | 一次调用中搜索项目并获取排名靠前的 n 条结果的完整详情(聚合) |
search_awards | 仅搜索已授予的合同(一次查询全部四种 Award 类型) |
get_procurement_details | 获取一条公告的完整记录 |
get_publication_history | 获取同一项目的较早公告(招标 → Award) |
search_cpv_codes | 将关键词解析为 CPV 分类代码 |
search_construction_codes | 瑞士建筑代码(BKP、NPK、eBKP、OAG、CPC) |
find_procurement_office | 按部分名称查找公共采购办公室 |
source_status | 检查 simap.ch API 的可访问性和延迟 |
所有工具都带有 readOnlyHint、idempotentHint 和 openWorldHint(它们会查询
实时的 simap.ch API)。
每个工具都接受单个经过验证的参数对象。范围、允许列表和模式在
inputs.py 中的输入模型里声明 —
因此,超出范围的限制值或未知州会在发起上游请求前被拒绝,而且约束会显示在工具
Schema 中,供模型查看,而不是隐藏在工具实现中:
search_procurements({"canton": "ZH", "query": "Schulhaus", "limit": 20})
这些模型设置了 strict=True(不会静默地将 "10" 强制转换为 10)和
extra="forbid"(未知字段会被拒绝,而非忽略)。州、流程
类型、公告类型、代码系统和语言的允许列表均来自 constants.py,因此不会
与探测验证过的表格不一致。
canton= 的含义
simap 只提供一个地理筛选器 orderAddressCantons,它按工作交付地点
筛选 — 而非按采购方筛选。当采购办公室提交自由文本地址时,结构化州字段为 null,
该公告便无法通过此筛选器找到。对瑞士全国范围内自 2026-07-01 起发布的
500 个项目进行测量:303 个(60.6%)未填写州,其中包括 Amt
für Hochbauten Zürich、Grün Stadt Zürich、USZ、BBL 和 SBB。
因此,canton_match 会明确说明筛选依据:
| 值 | 匹配结果 | 苏黎世,2026-07-01…27 |
|---|---|---|
procuring_body (默认) | 由该州的公共机构采购,包括市政和下属办公室(issuedByOrganizations) | 410 个项目 |
place_of_delivery | 工作在该州交付(orderAddressCantons) | 263 个项目 |
both | 两者的并集;两次上游调用,不分页 | 441 个项目 |
只有 place_of_delivery 能找到的 31 个项目,是联邦机构在苏黎世采购的项目(ETH、Empa、Flughafen Zürich AG)— 这是另一个问题,并非数据缺失,因此这里采用三种明确的语义,而不是静默取并集。
每个响应都会在 note 中说明所应用的语义。
组合关联
- 供应商的 UID 链接到
register-mcp。 - 招标中的 BKP / eBKP 建筑规范代码将采购与学校建筑规划关联起来,并链接到
zh-education-mcp。 - 与
amtsblatt-mcp互为补充,提供全国范围覆盖,而非仅覆盖三个州。
安装
uvx swiss-procurement-mcp
Claude Desktop
{
"mcpServers": {
"swiss-procurement": {
"command": "uvx",
"args": ["swiss-procurement-mcp"]
}
}
}
云端(Render / Railway)
MCP_TRANSPORT=sse HOST=0.0.0.0 PORT=8000 python -m swiss_procurement_mcp
容器
docker compose up --build # SSE on :8000
该镜像采用多阶段构建,并以非 root 系统用户运行。compose.yaml
会添加只读根文件系统、移除所有功能、设置
no-new-privileges,并限制内存、CPU 和 PID。运行时无需任何密钥——封装的 simap.ch 接口是公开的。
CI 会在每次推送时构建镜像,并验证两项关键属性:
容器不会以 uid 0 运行,且服务器在 --read-only --cap-drop ALL 下仍可导入。
配置
| 变量 | 默认值 | 用途 |
|---|---|---|
MCP_TRANSPORT | stdio | stdio | sse | streamable-http |
MCP_HOST / HOST | 127.0.0.1 | HTTP 绑定地址(仅限云端传输)。默认绑定到回环地址;在云端部署时,需显式设置为 0.0.0.0 以开放所有接口。 |
MCP_CORS_ORIGINS | (未设置) | 允许从浏览器调用 HTTP 传输的来源,以逗号分隔。未设置表示完全禁止浏览器跨域访问——不影响 stdio 和非浏览器客户端。对列出的来源,会公开并接受 Mcp-Session-Id,因此浏览器客户端可以保持会话。* 会被接受,但会记录警告并禁用凭据,因为浏览器不允许通配符来源与凭据同时使用。 |
MCP_STATELESS | (关闭) | 设置为 1,即可在无会话跟踪的情况下运行 streamable-http 传输。多实例部署时无需考虑会话亲和性;但会失去 SSE 流恢复和服务器发起通知的能力。对旧版 SSE 传输无效;若设置此项,会记录警告。请参阅 docs/load-balancing.md。 |
PORT / MCP_PORT | 8000 | HTTP 端口(仅限云端传输) |
LOG_LEVEL | INFO | DEBUG | INFO | WARNING | ERROR。始终输出结构化 JSON,每行一个对象,并写入 stderr——使用 stdio 传输时,stdout 用于承载 MCP 协议。 |
无需 API 密钥——封装的 simap.ch 读取接口完全公开。
基于 structlog 构建。工具调用期间发出的每个事件都带有该调用的
correlation_id,通过 contextvars 绑定——因此,即使错误是在 HTTP 客户端深处记录的,也能将其与引发错误的请求关联起来,无需在每个函数间传递上下文。
| 级别 | 触发时机 |
|---|---|
DEBUG | 开始处理工具调用(tool_call_started)——可据此确认挂起的调用是否曾经启动 |
INFO | 工具调用成功完成,并记录延迟 |
WARNING | simap.ch 无法访问或发生错误(upstream_degraded) |
ERROR | 工具调用引发异常 |
记录中仅包含异常类型——绝不包含异常消息,也不包含上游响应正文(OBS-002)。
{"event":"tool_call_started","tool":"search_procurements","correlation_id":"23221af26ae640c7","level":"debug","timestamp":"2026-07-27T22:20:07.494276Z"}
{"status":"ok","latency_ms":312,"event":"tool_call","tool":"search_procurements","correlation_id":"23221af26ae640c7","level":"info","timestamp":"2026-07-27T22:20:07.806Z"}
MCP 协议版本
通过 initialize 握手提供 | 2024-11-05 … 2025-11-25 — 握手上限 |
| 通过每请求信封提供 | 2026-07-28 |
| 由谁选择 | 客户端在每个连接上发送的第一个请求。携带 2026-07-28 _meta 信封的请求会建立现代连接;其他请求则会建立握手连接。 |
| 固定于 | server.py 中的 MCP_PROTOCOL_VERSION |
| SDK | mcp>=2.0.0,<3 |
| 缓存提示 | tools/list、server/discover、prompts/list、resources/list、resources/templates/list:ttlMs 300000,cacheScope public |
| 服务身份 | server/discover 会返回 name、title、version(已安装发行版的版本)和 websiteUrl,以及服务级 instructions |
| 验证方式 | tests/test_modern_era.py — 通过 HTTP 和 stdio 传输发送真实的 2026-07-28 信封请求 |
MCP Python SDK 在会话层协商协议版本,且不提供用于设置版本的构造函数参数,因此无法通过配置固定版本。版本通过声明常量固定,并通过检测强制执行:
- 运行时,如果常量与 SDK 不一致,会以
WARNING级别记录protocol_version_drift事件。服务仍会继续运行。 - CI 中,
tests/test_protocol_version.py会失败。
这是有意为之的区分。SDK 升级应当导致我们的构建失败,而不是让在自己环境中升级了 mcp 的用户无法运行。
两个时代实际有哪些不同
以前会通过 SDK 常量和一次 initialize 往返来检查固定的版本。该往返只能到达握手时代,其上限为 2025-11-25 — 因此,本仓库此前从未发送过 2026-07-28 请求。服务可以固定到一个它会用 400 拒绝的版本,却仍然通过测试。
在 2026-09-18 使用 mcp 2.2.0,通过两种传输方式测得:
-
没有握手。
server/discover取代了握手,也是原生客户端唯一能够查询服务身份的入口。每个请求都携带自己的_meta信封(协议版本、客户端信息、客户端能力);没有该信封时,服务会返回-32602。 -
请求按标头路由。
Mcp-Method和Mcp-Name必须与请求正文一致,否则请求会在工具运行前被拒绝,并返回-32020— 因此,只读取标头的代理无法将请求指向与正文中名称不同的工具。(此处未使用Mcp-Param-*;请参阅_cors.py。) -
initialize会得到三种不同的响应,差异并非由传输方式造成。 本文最初将 HTTP 和 stdio 的情况称为同一情形的“两面”;但两者同时在两个变量上有所不同:传输方式和历史记录,因此任何一方都无法解释另一方。测得的三个情况如下:情况 响应 单独通过 HTTP 发送带信封的 initialize-32601METHOD_NOT_FOUND,HTTP 404通过 stdio 在现代请求之后发送 initialize-32022UNSUPPORTED_PROTOCOL_VERSION通过 stdio 将带信封的 initialize作为第一个请求发送已接受 — 2025-11-25握手不存在第四种情况:通过 stdio 在没有历史记录时发送
initialize,会建立旧版连接,而非被拒绝,因此无法在那里重现 HTTP 的情况。请注意第三行 — 为initialize添加信封后,会悄然建立一个较旧时代的连接,且没有任何信息表明信封已被忽略。 -
不同时代读取能力的方式不同,这是 SDK 的行为,并非此处的设置。 在
2026-07-28下,SDK 会根据一个事实推导tools.listChanged、prompts.listChanged、resources.listChanged和resources.subscribe— 即是否提供了subscriptions/listen;而MCPServer会无条件注册它。因此,对于同一服务,server/discover会将这四项报告为true,而initialize则会将它们报告为false。
更新策略
- Dependabot 每月创建 SDK 更新 PR(
.github/dependabot.yml)。 - SDK 更新导致协议版本变化时,CI 测试会失败。解决方法不是盲目修改常量:应阅读规范变更日志,了解两个版本之间的变化,确认服务行为仍然正常,然后在一个提交中更新常量、本节和
CHANGELOG.md。 - 协议版本升级会在
CHANGELOG.md中明确说明,不会合并到依赖更新条目中。
基元:仅提供工具
此服务仅提供工具,不提供资源或提示词。这是经过考虑的决定,并非遗漏,原因如下(ARCH-008)。
为什么不提供资源。 资源适用于可识别、可列举的内容——类似 GET 的读取,客户端可以枚举并缓存。simap 的端点恰恰相反:每次有用的调用都是一个带过滤条件的查询,针对约 20 万条且每天都在变化的出版物语料库。资源 URI 要么会枚举无界内容,要么会在 URI 中编码完整查询,而后者本质上就是多了几步的工具。
我们具体评估了两个工具是否适合迁移,并因特定原因否决,而非一概而论:
| 候选项 | 保留为工具的原因 |
|---|---|
source_status | 它确实具有资源的特征——是一份固定且可缓存的文档。但它的用途是在结果看起来有误时调用;模型必须记得重新读取的资源,不如模型发现可疑时能直接调用的工具适合这项工作。 |
search_cpv_codes | CPV 目录有限且足够稳定,可以枚举。但它约有 1 万条;将其作为资源公开会把整个分类目录塞入上下文窗口,而这个工具的意义恰恰在于由服务端执行查询。 |
为什么不提供提示词。 精心整理的提示词列表会收录问题模板(“X 州有哪些招标……”)。工具文档字符串已经在模型实际读取的位置提供了这些指引;提示词则会在第二处重复这些内容,并可能出现不一致——这个仓库已经两次遇到过这类重复造成的问题。
如果该服务将来新增真正可枚举且变化缓慢的数据集,我们会重新审视这一决定。
测试
PYTHONPATH=src pytest tests/ -m "not live" # offline, respx-mocked
PYTHONPATH=src pytest tests/ -m live # hits the real API
请参阅 EXAMPLES.md,其中按受众(学校、公众、行政部门、开发者)分类列出了使用案例,并提供了工具选择参考表。
已知限制
- 索引的是项目,而非出版物。
project-search会索引项目,并以其最新出版物表示每个项目。一个 3 月招标、7 月授标的项目只会出现一次,显示为 7 月的授标;search_awards同样只会找到最新出版物是授标的项目,因此后续更正会使其无法显示。get_publication_history可以查到较早的出版物。 - 按标段追踪分标采购。 上游按标段保存出版物历史,因此搜索结果显示
lots_type: "with"时,get_publication_history需要提供lot_id——请从该结果的lots列表中取一个。不提供时,源站会返回 HTTP 400,工具则会报告降级响应,并指出缺少的参数。2026-08-29 对 80 条出版物的测量结果:所有 4 条含标段的记录均如此,所有 76 条不含标段的记录均可直接查询。 - 必须至少指定一个过滤条件。 simap 对不带过滤条件的查询不会返回所有内容,而是返回空结果,因此工具会以此为由拒绝查询,而不是报告空结果。
- 按设计只读。 simap API 中存在发布和提交端点,但有意未将其封装为工具。
- 各州的授标覆盖情况不均衡:有些州会认真发布授标信息,有些州则很少发布。没有授标记录并不能证明没有发生授标。
- 搜索结果不包含合同金额。 已发布的金额位于详细记录的统计信息部分,而该部分因采购程序而异。
- 非官方客户端。 simap.ch 本身的出版物仍是权威信息。
项目结构
swiss-procurement-mcp/
├── src/swiss_procurement_mcp/
│ ├── server.py # MCPServer tools (9, read-only)
│ ├── client.py # simap.ch HTTP client + retry + normalisation
│ ├── constants.py # probe-derived lookup tables (cantons, pub types, codes)
│ ├── models.py # Pydantic v2 envelopes (source + provenance)
│ ├── inputs.py # strict Pydantic tool-input models (bounds, allow-lists)
│ ├── _fuzzy.py # term widening for the taxonomy lookups (ARCH-003)
│ ├── _log.py # structured JSON logging to stderr + @logged_tool
│ ├── _net.py # DNS-pinned transport (egress allow-list)
│ ├── _cors.py # CORS layer for the HTTP transports
│ └── __main__.py # Dual-transport entry point (stdio / SSE / streamable-http)
├── tests/ # respx-mocked + @pytest.mark.live
└── .github/workflows/ # CI + OIDC PyPI/MCP-registry publish
为什么没有 tools/ 包
作品集结构标准要求,服务公开的工具超过五个时,应使用 tools/ 包。此项目公开了九个工具,却将它们保留在 server.py 中。这是有意为之,并非疏忽——在这里记录,是因为标准以及任何对照标准的人都会查看此处。
server.py 约有 900 行,而周边模块已经按职责拆分,因此标准的意图——让代码库无需滚动浏览一个包罗万象的文件也能轻松导航——已经实现。拆分后增加的只是字面上的文件布局。
配套服务 amtsblatt-mcp 就是值得拆分的情况:它的 server.py 曾膨胀到 2477 行,其中包含 HTTP 管理代码、XML 解析、分类缓存、输入模型和所有处理器,并在该项目的 0.21.0 版本中完成拆分。这次重构也正是此处保持谨慎的原因——它引入了一个缺陷(提取出的模块按值捕获了一个全局缓存,因此某个工具会悄无声息地报告过时状态),而整个测试套件都未能发现,因为没有测试覆盖受影响的路径。这个问题是通过审阅差异发现的。
为了满足标准的字面形式而迁移九个处理器,却不会带来任何导航上的好处,还会承担上述风险。若 server.py 超过约 1500 行,则应重新审视此事。
成熟度与更新
阶段 1——只读(有关各阶段的待办事项,以及阶段转换所需条件,请参阅 ROADMAP.md)。此服务仅封装公开的只读端点;写入端点/受 OIDC 保护的 simap 端点刻意不在范围内。有关可能推动其进入写入阶段的条件,请参阅 SECURITY.md 中的重新评估触发条件。
此服务以固定的 MCP 规范版本 MCP_PROTOCOL_VERSION 为目标——当前值及固定方式请参阅上文的 MCP Protocol Version。SDK 和依赖项更新会以 Dependabot PR 的形式提交,因此会经过审慎审查,不会让破坏性协议或 SDK 变更悄然混入。
贡献
欢迎贡献——有关如何报告错误、建议新端点或提交代码,请参阅 CONTRIBUTING.md。
安全
这是一个只读、不涉及个人身份信息(PII)的公共开放数据服务。已依据作品集 MCP 最佳实践目录完成审计(32 项适用检查中,15 项通过/16 项部分通过/1 项未通过,已可用于生产环境)。有关安全状况及漏洞报告方式,请参阅 SECURITY.md;完整报告见 audits/。
许可证
MIT 许可证——请参阅 LICENSE。这些招标公告是官方公共采购公告;simap.ch 未发布明确的开放数据许可证,因此数据复用须遵循 simap.ch 条款(见致谢)。
作者
Hayal Oezkan · github.com/malkreide
更新日志
请参阅 CHANGELOG.md。
致谢
- 数据:simap.ch 只读 API v1.5.1,由 simap.ch 协会运营。API 文档:simap.ch/api-doc——机器可读的 OpenAPI 规范位于
/api/specifications/simap.yaml,实时测试会检查枚举常量是否与之匹配。指南:kissimap.ch。 - 底层招标公告是瑞士公共机构发布的官方公共采购公告。simap.ch 未发布明确的开放数据许可证;数据复用须遵循 simap.ch 条款。请注明来源为 simap.ch (Verein simap.ch)。
- 按照
mcp-data-source-probe方法构建。
本仓库中的代码采用 MIT 许可证;数据归 simap.ch 所有,须遵循其条款(见上文)。公共资金,公共代码。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。