baumarkt-mcp

baumarkt-mcp

MCP server that lets an LLM search and compare prices across four German hardware-store retailers (Hornbach, BAUHAUS, Globus, OBI), with store pickup availability for local branches.

Category
访问服务器

README

baumarkt-mcp

An MCP server that lets an LLM search four German hardware-store (Baumarkt) retailers — Hornbach, BAUHAUS, Globus Baumarkt and OBI — through one tool surface. Built for self-hosting: one container, no API keys, no per-query credits. The point is the single compare_price call: it fans out over all four retailers and answers "who sells this cheapest, and can I collect it locally today" — every result carries a store_pickup field scoped to the local (Braunschweig-area) branches.

Companion to jnslmk/geizhals-mcp (new retail goods) and jnslmk/kleinanzeigen-mcp (second-hand classifieds).

Tools

Tool What it does
search_products Keyword search of one retailer or all four, with an optional client-side max_price filter. Returns product summaries.
get_product Full detail for one product id at one retailer.
compare_price Fan-out over all four retailers, sorted cheapest first — the "who sells this cheapest, can I collect it locally today" answer in one call.

The intended flow is compare_price (or search_products with retailer="all") → pick interesting ids → get_product. Every result is one normalised product with the same shape: retailer, id, name, brand, gtin, price, currency, availability, url, image, store_pickup.

search_products

search_products(query, retailer="all", store=None, max_price=None, max_results=20)
  • retailer"all" (default) fans out over hornbach, bauhaus, globus and obi; or exactly one of those four names.
  • store — optional branch/store identifier; None uses each retailer's default (Braunschweig) branch. Per-retailer semantics in "The retailers".
  • max_price — maximum price in EUR (≥ 0), typed str | int because LLMs routinely send "600" as a string and the schema accepts it. Filters results client-side after fetching, so a product with no listed price (price: null) cannot be filtered and is kept.
  • max_results — per-retailer cap, also str | int, hard-capped at BM_MAX_RESULTS (default 50).

Returns {query, returned, results, errors}results is the flat list of matching products across the requested retailers, errors the per-retailer failures.

get_product

get_product(retailer, product_id, store=None)

retailer must be exactly one of the four names; product_id is the retailer's own id/sku as returned by a search. Returns {retailer, product, error}product is null when the id does not resolve, or when the retailer fails. A retailer failure is additionally reported in error (a non-null message) so it is distinguishable from id-not-found, where error is null.

compare_price

compare_price(query, store=None, max_results=20)

Always fans out over all four retailers and sorts the merged results by price ascending — products with no listed price sort last. Returns {query, returned, results, errors}.

Failure handling

A failing retailer never fails the call. Each retailer runs independently and a failure becomes an entry in the tool's errors list while the other retailers' results are still returned. "Blocked" is never collapsed into "no results": a bot-wall timeout reads as blocked by a challenge: ... (or blocked by a CAPTCHA: ..., browser pool exhausted: ...), which is a different answer from an empty result list.

The bauhaus limitation

bauhaus.info sits behind a Cloudflare Turnstile challenge. The adapter deliberately does not automate interaction with the challenge widget — presenting a real browser and being passively let through is one thing, scripting the widget itself is a different thing this project does not do. So when bauhaus serves the interactive checkbox variant (or a hard or slow challenge), searches through this server return no bauhaus results and the tool reports a clear blocked-by-challenge error instead: blocked by a challenge: bauhaus served an interactive Cloudflare Turnstile challenge (the checkbox variant, ...) — this adapter deliberately does not attempt to clear it.

This is intended behaviour — the deliberate cost of not defeating the bot-control. bauhaus is the least reliable of the four retailers and may routinely fail; the other three are unaffected, and every tool output makes it explicit which retailer(s) failed rather than pretending bauhaus had nothing.

The retailers

Retailer Approach Bot wall Local store
Hornbach JSON-LD only (search ItemList, detail Product), headed Chromium F5/Fastly "Client Challenge"; headless escalates to an image CAPTCHA Braunschweig, branchCode 615
BAUHAUS Captures the page's own /api/products responses, never replayed; JSON-LD fallback Cloudflare Turnstile (two variants — see above) Braunschweig, storeId 607
Globus FactFinder-rendered listing, DOM-parsed; JSON-LD on the detail page none observed (defensive check only) Braunschweig, store id 212
OBI Plain HTTP (httpx), no browser none none — online pricing only, never pickup

Hornbach

hornbach.de sits behind an F5/Fastly "Client Challenge" bot wall. Plain HTTP gets served a ~3 KB challenge body with HTTP 200 — indistinguishable from success by status code alone. Headless Chromium escalates to an image CAPTCHA; headed Chromium clears the challenge effectively immediately. There is no DOM scraping: the search page (/s/<query>) carries a schema.org ItemList JSON-LD block, and the detail page (/p/<slug>/<sku>/) a Product block with sku and gtin13. The page carries at most one pickup offer — for the visitor's default branch (Braunschweig, branchCode 615); the store parameter filters among whatever pickup offer the page carries but cannot fetch another branch's real availability.

BAUHAUS

bauhaus.info is behind a Cloudflare managed challenge (Turnstile) plus its own branded WAF block page. The search results API (/api/products?productIds=...&filter=...) returns clean JSON — but re-requesting that exact URL from inside an already-cleared page gets the branded 403 page back, never JSON. So the adapter never constructs or replays that URL: it registers a page.on("response", ...) handler before navigating and reads whatever the page's own JavaScript requests, dict-merging the results as they arrive (the search page's ItemList JSON-LD is the fallback). A detail page carries no price in its JSON-LD either — the price is pulled out of the embedded server-rendered payload, and captured /api/purchasability responses provide availability and a real per-store pickup signal. The selectedStore cookie pins the storefront to Braunschweig (storeId 607). Search results have no confirmed per-store signal, so their store_pickup is None rather than a guess.

Globus Baumarkt

globus-baumarkt.de is Shopware 6 with a FactFinder search integration; the results listing is client-rendered and carries no JSON-LD, so search drives a headed browser and parses the rendered cards from the DOM (/search/result?query=..., paginated with &p=<n>, at most 5 pages). The detail page carries a schema.org Product JSON-LD block (name, sku, gtin13, brand, offers) plus a server-rendered "In diesem Markt" widget that gives a real store_pickup reading. get_product looks an article number up by searching for it — a unique hit redirects straight to the product detail page. A fresh browser context resolves to the Braunschweig branch (store id 212); the store parameter is accepted for signature parity but is not wired to store-switching and logs a warning when passed.

OBI

obi.de has no bot wall (verified: a plain-HTTP request with a browser-shaped User-Agent returns real search results), so this adapter is plain httpx — no browser, no Xvfb, and it never touches the shared browser pool. search parses the window.__INITIAL_STATE__ JSON blob embedded in the search page (/search/<query>/); get_product parses the Product JSON-LD on the detail page (/p/<id>). OBI has no Braunschweig-area branch, so it is an online-only price source here: every result has store_pickup: null and store is accepted but ignored — an OBI price is never a pickup price.

How it works

Three of the four retailers sit behind bot walls, and headless Chromium does not clear them: on hornbach.de it gets served an image CAPTCHA, on bauhaus.info a hard 403. The container therefore runs headed Chromium under Xvfb, driven by patchright (a patched, undetected Playwright fork with the same async API). One BrowserManager owns the browser and a small pool of reusable contexts (BM_MAX_CONCURRENT, default 2): a context that has already cleared a wall keeps its cookies, so reusing it is more valuable than starting cold again. wait_for_challenge_clear polls the page every 250 ms and recognises both wall shapes — hornbach's F5/Fastly challenge (raising CaptchaRequired on the unrecoverable image-CAPTCHA variant) and bauhaus's Turnstile/WAF pages — so a wall that never clears ends as an explicit blocked by a challenge error, never as an empty result. obi is excluded from the pool entirely.

Running it

docker build -t baumarkt-mcp .
docker run --rm -p 8000:8000 baumarkt-mcp

Streamable HTTP at http://localhost:8000/mcp, plain GET /healthz for healthchecks (the image ships a matching Docker HEALTHCHECK). Give the container at least 1.5 GB of memory — Chromium under Xvfb is the heavy part.

The entrypoint starts Xvfb (display BM_DISPLAY, default 99, screen BM_SCREEN, default 1366x900x24), waits for the X socket to appear, then execs the server as PID 1. It deliberately does not use xvfb-run: its signal handshake races as PID 1 in a container and can leave Xvfb up but the server never binding its port.

Configuration

Variable Default Purpose
MCP_TRANSPORT http http (streamable HTTP) or stdio
MCP_HOST 0.0.0.0 Bind address
MCP_PORT 8000 Bind port
MCP_PATH /mcp MCP endpoint path
BM_MAX_CONCURRENT 2 Browser contexts to pool — pool size and concurrency limit in one
BM_MAX_RESULTS 50 Hard cap on products per retailer per search
BM_HEADLESS 0 1 runs headless — local development only, will not clear the hornbach/bauhaus walls
BM_CHALLENGE_TIMEOUT_MS 25000 How long to wait for a bot-wall challenge to clear
BM_PROXY (none) Egress proxy URL for the browser, e.g. http://10.0.0.5:8888
BM_PROXY_USERNAME / BM_PROXY_PASSWORD (none) Optional proxy auth
BM_DISPLAY 99 Xvfb display number (entrypoint)
BM_SCREEN 1366x900x24 Xvfb screen geometry (entrypoint)
LOG_LEVEL INFO Python log level

The BM_* defaults are tuned for a chat agent making one request at a time. Raise BM_MAX_CONCURRENT for throughput, at the cost of memory and a greater chance of tripping these sites' bot detection.

LibreChat

mcpServers:
  baumarkt:
    type: streamable-http
    url: "http://baumarkt-mcp:8000/mcp"
    timeout: 180000
    chatMenu: true

mcpSettings:
  allowedAddresses:
    - "baumarkt-mcp:8000"

allowedAddresses is required — LibreChat's SSRF guard blocks MCP URLs that resolve to private addresses, which a sibling container always does. The timeout is generous because a cold call starts a browser and may wait out a bot-wall challenge.

Claude Code

claude mcp add --transport http baumarkt http://localhost:8000/mcp

Development

uv venv && uv pip install -e .
patchright install chromium
BM_HEADLESS=1 MCP_TRANSPORT=stdio uv run python -m baumarkt_mcp

Headless is fine for a quick local check; the container default (headed under Xvfb) is what actually clears the bot walls. MCP_TRANSPORT=stdio runs the server as a local stdio server instead of HTTP.

Lint with ruff check and ruff format --check. The globus adapter ships a dependency-free regression check runnable directly:

python -m baumarkt_mcp.retailers.globus

It feeds synthetic listing HTML — including the discounted-card shape that once produced a wrong-but-plausible price — through the real parsing path and asserts the correct sale price comes out. No network involved.

Images

ghcr.io/jnslmk/baumarkt-mcp — multi-arch (linux/amd64, linux/arm64), built by GitHub Actions on native runners for each architecture.

Tag Meaning
latest Newest build of main
sha-<full-sha> A specific commit
v0.1.0, v0.1 Release tags

Caveats

None of these retailers has a public API, so this server scrapes their sites with a real browser (or, for obi, plain HTTP). It can break whenever they change their markup or tighten their bot walls, and heavy or parallel use may trip bot detection. Scraping is also at odds with the retailers' terms of service. Keep it to personal-scale use — the conservative concurrency defaults exist for exactly this reason.

License

MIT — see LICENSE.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选