Baumarkt
БесплатноНе проверенMCP server that lets an LLM search and compare prices across four German hardware-store retailers (Hornbach, BAUHAUS, Globus, OBI), with store pickup availabili
Описание
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.
README
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;Noneuses each retailer's default (Braunschweig) branch. Per-retailer semantics in "The retailers".max_price— maximum price in EUR (≥ 0), typedstr | intbecause 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, alsostr | int, hard-capped atBM_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.
Установка Baumarkt
У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.
▸ github.com/jnslmk/baumarkt-mcpFAQ
Baumarkt MCP бесплатный?
Да, Baumarkt MCP бесплатный — установка в пару кликов через Unyly без оплаты.
Нужен ли API-ключ для Baumarkt?
Нет, Baumarkt работает без API-ключей и переменных окружения.
Baumarkt — hosted или self-hosted?
Self-hosted: сервер запускается локально на твоей машине командой из раздела установки.
Как установить Baumarkt в Claude Desktop, Claude Code или Cursor?
Открой Baumarkt на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.
Похожие MCP
Fetch
Web content fetching and conversion for efficient LLM usage.
AWS KB Retrieval
Retrieval from AWS Knowledge Base using Bedrock Agent Runtime.
автор: modelcontextprotocolSpring AI MCP Server
Provides auto-configuration for setting up an MCP server in Spring Boot applications.
llm-analysis-assistant
A very streamlined mcp client that supports calling and monitoring stdio/sse/streamableHttp, and can also view request responses through the /logs page. It also
автор: xuzexin-hzCompare Baumarkt with
Не уверен что выбрать?
Найди свой стек за 60 секунд
Автор?
Embed-бейдж для README
Похожее
Все в категории ai
