Command Palette

Search for a command to run...

UnylyUnyly
Весь каталог

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

GitHubEmbed

Описание

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; 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.

from github.com/jnslmk/baumarkt-mcp

Установка Baumarkt

У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.

▸ github.com/jnslmk/baumarkt-mcp

FAQ

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

Compare Baumarkt with

Не уверен что выбрать?

Найди свой стек за 60 секунд

Автор?

Embed-бейдж для README

Похожее

Все в категории ai