# Fabular demo — MCP / agentic-commerce audit

**Target:** `https://fabular.pages.dev` (the "Fresh Haven" fabular showcase) — the new **MCP server** surface.
**Date:** 2026-06-06 · **Method:** read-only live probing (no DB writes). Genre: competitive-mechanics audit.
**Lineage:** Round 1–2 audited the storefront's digital health (a11y / CWV / AI-readiness / product-data / legal). This round audits the *agentic* surface fab4minds added since: a working Model Context Protocol server at `/api/mcp`, an `/llms.txt`, and an `/mcp` landing page. Homepage now markets **"🔌 MCP-ready"** and **"MCP-Server verfügbar"**.

---

## TL;DR

It's real, and it works. fab4minds shipped a **functioning MCP server** — JSON-RPC 2.0, 9 well-schema'd tools (search / cart / offers / rescue-food / recommendations), live data, correct protocol-level errors. Connectable from Claude Desktop and ChatGPT per the `/mcp` page's own `npx mcp-remote` instructions. **This moves the fabular ceiling again** — from "AI-*readable*" (rounds 1–2) to "agent-*transactable*." Essentially the entire StorePulse roster is JS-walled and invisible to LLMs (2026-06-05 audit: tenants KI Ø 3.6, 100 % JS-walled); a working MCP storefront is a genuine differentiation story.

The gaps are **productization-readiness gaps, not gotchas** — the kind a demo can carry but a live tenant with real orders cannot:

1. **Session isolation is not provided through the supported client path** — the server issues no spec `Mcp-Session-Id`; isolation is keyed on a bespoke `x-session-id` header that the documented `mcp-remote` connection never sends. Harmless in this ephemeral, checkout-less demo; a **blocker once carts hold real orders**.
2. **Tool errors never set the MCP `isError` flag** — failures return `isError:false` + HTTP 200 with the error as prose, so an agent can't reliably detect failure or self-correct (contra MCP SEP-1303).
3. **Data bugs surface verbatim through the agent** — every rescue-food item is dated **MHD 1970-01-0X** (Unix-epoch artifact) in the flagship Lebensmittelrettung feature; weight-named products are tagged `Stück` (breaks per-kg comparison); `llms.txt` claims "über 40 Produkte" while the catalog is **≥421**.

And the standing through-line: this is still the **showcase demo**, not a live `/ACM/` fabular tenant. The MCP server proves the *platform can* make a shop agent-shoppable — not that any tenant's shop is.

---

## 1. It works — credit first

Genuine JSON-RPC 2.0 over a single POST endpoint (Streamable-HTTP style). Full client handshake succeeds:

```bash
curl -s -X POST https://fabular.pages.dev/api/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"audit","version":"0.1"}}}'
```
```json
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",
 "capabilities":{"tools":{},"resources":{}},
 "serverInfo":{"name":"fresh-haven-mcp","version":"1.0.0"},
 "instructions":"Du bist mit dem Fresh Haven Bio-Lebensmittel-Shop verbunden. Nutze product_search …"}}
```

`tools/list` returns **9 tools**, each with a proper JSON-Schema `inputSchema` (enums, required-arrays, defaults, ranges):

| Tool | Purpose |
|---|---|
| `product_search` | full-text + filters (category, bio, offer, maxPrice, minRating, limit) |
| `product_detail` | full product record by sid |
| `cart_get` / `cart_add` / `cart_remove` / `cart_clear` | cart CRUD |
| `offers_list` | current promotions with EUR + % savings |
| `rescue_food_list` | near-MHD discounted items (Lebensmittelrettung) |
| `recommendations_get` | personalised by lifestyle / goals / budget (well-designed enums) |

`tools/call` returns live, structured data — e.g. `product_search "karotten"` returns 10 hits with price, unit, rating, offer flags, and a real product URL. Protocol-level errors are **correct**: an unknown method returns a proper JSON-RPC error.

```bash
# unknown method → correct JSON-RPC -32601
curl -s -X POST .../api/mcp -d '{"jsonrpc":"2.0","id":5,"method":"resources/list"}'
# {"jsonrpc":"2.0","id":5,"error":{"code":-32601,"message":"Method not found: 'resources/list'"}}
```

The `/mcp` page documents both Claude Desktop and ChatGPT, with a copy-paste `claude_desktop_config` using `npx mcp-remote` against the endpoint. This is a complete, demonstrable agentic-commerce path. Nobody else in the DACH bio-delivery market has one.

---

## 2. Spec-compliance gaps (productization-readiness)

Verified against the live MCP specification (`modelcontextprotocol/modelcontextprotocol`, current revision **2025-11-25**) — citations below are spec-confirmed, not asserted from memory.

### 2.1 — Session isolation is absent through the supported client path  🔴 *blocker for the productized version*

**Observed (reproducible):**
- The server issues **no `Mcp-Session-Id`** header on `initialize` (the spec's Streamable-HTTP session mechanism). Only a CORS line is returned, advertising bespoke headers:
  ```
  access-control-allow-headers: Content-Type, x-session-id, x-mcp-session
  ```
- Carts **do** isolate when the client supplies `x-session-id` — proven: session `AAA` adds 3× Bio-Karotten, session `BBB` then sees an empty cart:
  ```bash
  curl ... -H 'x-session-id: AAA' -d '{...cart_add art-1001 qty 3...}'   # "✓ 3× Bio-Karotten …"
  curl ... -H 'x-session-id: BBB' -d '{...cart_get...}'                  # item_count: 0, items: []
  ```
- But the `/mcp` page's documented connection (`npx mcp-remote`) sends **no** `x-session-id`. Two header-less requests share one default bucket (`cart_add` then `cart_get` → `item_count: 1`).

**Therefore (inference, scoped honestly):** for a real MCP client following the page's own instructions, cart isolation is **not guaranteed**. The exact failure mode (all users collide / per-IP collision / state lost across serverless isolates) can't be pinned from a single egress IP and isn't worth pinning — every one of them is wrong for real orders.

**Severity:** harmless *now* (ephemeral demo, no auth, no checkout, fictional cart). A correctness **blocker** the moment this template backs a live tenant: the fix is to adopt the spec `Mcp-Session-Id` flow (server-assigned on `initialize`, client-echoed) and/or authenticated sessions — not a custom header invisible to standard clients.

### 2.2 — Tool errors never set the MCP `isError` flag  🟠

Tool-execution failures return `isError:false` and HTTP 200, with the error as German prose:

```bash
# unknown tool
{"result":{"content":[{"type":"text","text":"{ \"error\": \"Unbekanntes Tool: 'checkout'. …\" }"}],"isError":false}}
# cart_add with the required product_sid omitted (inputSchema marks it required)
{"result":{"content":[{"type":"text","text":"{ \"success\": false, \"error\": \"Produkt 'undefined' nicht gefunden.\" }"}],"isError":false}}
```

The MCP spec (SEP-1303, *input-validation errors as tool-execution errors*) is explicit: tool failures — including bad/missing arguments — **should return `isError: true`** in the result so the model detects failure and self-corrects (its canonical example is a `book_flight` past-date rejection returned with `isError:true`). Here the flag is **never** set true, and failure shapes are ad-hoc (sometimes `success:false`, sometimes a bare `error` string). An agent must do tool-specific prose parsing to know a call failed — it will otherwise narrate *"Produkt undefined nicht gefunden"* to the user or proceed as if the add succeeded. Protocol-level errors are fine (§1); only the tool layer is affected.

### 2.3 — No server-side schema validation  🟠

`cart_add`'s `inputSchema` declares `required:["product_sid"]`, but omitting it isn't rejected — the server proceeds with `undefined` (see 2.2). The schema is advertised to clients but not enforced server-side.

### 2.4 — Protocol revision & transport subset  ⚪ *note, not a defect*

The server implements the **original `2024-11-05`** revision (it answers `2024-11-05` to a `2025-06-18` request — valid negotiation for a server that only implements the first revision; current spec is `2025-11-25`, ~four revisions on). `GET /api/mcp` returns a static manifest rather than an SSE stream — an acceptable Streamable-HTTP subset. Both fine for a demo; worth knowing the implementation tracks the earliest spec.

---

## 3. Data quality, seen through the agent

The MCP surface is unforgiving: whatever's in the data, the LLM repeats to the user verbatim.

### 3.1 — Rescue-food MHD dates are all January 1970  🔴 *most quotable bug*

`rescue_food_list` — the flagship Lebensmittelrettung feature — returns a Unix-epoch artifact on **every** item (definitive, systemic, not one row):

```
Bio-Karotten 1 kg            mhd=1970-01-03  -60%
Heumilch 3,8% 1 l            mhd=1970-01-02  -55%
Sauerteigbrot 750 g          mhd=1970-01-03  -59%
Bergkäse 200 g               mhd=1970-01-04  -62%
Direktsaft Apfel 1 l         mhd=1970-01-05  -60%
Bio-Vollmilchjoghurt 500 g   mhd=1970-01-02  -62%
```

An agent asked *"what's about to expire?"* tells the user the food is **best-before January 1970**. The cause is plainly `epoch(0) + N days` instead of `now + N days`. It lands in the single most emotionally-loaded feature.

### 3.2 — `unitOfMeasure` breaks per-unit comparison  🟠 *definitive*

Weight-named products carry `unitOfMeasure: "Stück"`: e.g. `Bio-Karotten 2 kg` and `Karotten bunt 500g` are `Stück`, while `Bio-Karotten` (1 kg) is correctly `kg`. The classic agent task — *"cheapest carrots per kg"* — produces wrong math, because the unit the agent divides by is inconsistent with the weight in the name.

### 3.3 — `llms.txt` undercounts the catalog ~10×  🟠

`llms.txt` (the file whose entire job is briefing LLMs) states *"über 40 … Produkte in 12 Hauptkategorien."* A broad `product_search` sweep returns **≥421 distinct sids** — the catalog is an order of magnitude larger than the file claims. An LLM relying on `llms.txt` for breadth will under-represent the shop.

### 3.4 — Suspected duplicate product  ⚪ *hedged — not certain*

`product_search "karotten"` returns both `art-61033` "Karottenkuchen Stück" @ **2,99 €** and `art-40051` "Karottenkuchen-Stück" @ **3,29 €** — likely the same item with two sids and two prices (could conceivably be two bakeries; the near-identical name suggests a dedup gap). An agent would surface both and may quote the wrong price.

---

## 4. Strategic so-what

- **The ceiling moved, again.** Round 1–2 proved fabular can be AI-*readable* (KI 100, clean schema, valid `/llms.txt`). This round proves it can be agent-*transactable* — a live MCP server an LLM can search and fill a cart against. That's genuinely ahead of the market this repo tracks.
- **But it's still the demo, not a tenant.** `fabular.pages.dev` remains the static "Fresh Haven" showcase (no `/ACM/` API — confirmed in round 1). The MCP server is a **platform-capability demonstration** — "fabular shops *can* be agent-shoppable" — not evidence any live tenant exposes one. The demo was also rebuilt since round 2: now Vienna/AT-framed (`fresh-haven.at`) with a tighter story, though the catalog underneath is still ≥421 products (§3.3) — not the 40 it advertises.
- **The gap to the roster is now wider, not narrower.** A JS-walled live tenant (the documented norm) isn't crawlable, let alone agent-transactable. The demo shows the destination; the tenants haven't left the parking lot. That's the pitch shape, unchanged: *prerender the tenant shops first* — then MCP is a meaningful next layer rather than a showroom feature.
- **Notably absent: a checkout/order tool.** The funnel stops at cart — the safe call for a public, unauthenticated demo (no agent can place a real order). It also means the cart-isolation gap (§2.1) is currently low-stakes; it becomes load-bearing exactly when a future `order_place` tool and auth arrive.

---

## Forwardable punch-list (cheap, high-signal)

If this goes to fab4minds, four concrete items — three are one-line fixes:

1. **MHD dates** (§3.1) — `now + N days`, not `epoch + N days`. One-liner; biggest credibility win.
2. **`isError: true`** on tool failures (§2.2) — set the flag and use a consistent error shape so agents self-correct. Spec-aligned (SEP-1303).
3. **`unitOfMeasure`** (§3.2) — set weight units on weight-sold items so per-kg comparison works.
4. **Session model** (§2.1) — before any productized/tenant rollout, move to spec `Mcp-Session-Id` (or auth) so isolation works for standard clients, not just ones sending a custom header. The blocker, not a polish item.

Plus: align `llms.txt`'s "40+" with the real ≥421 catalog (§3.3); dedup `Karottenkuchen` if confirmed (§3.4).

---

## Recheck 2026-06-06 (delta) — same-day re-probe, three fixes already landed

Re-ran the full read-only curl battery against `/api/mcp` a few hours after the audit above. fab4minds had **already shipped three of the four punch-list items the same day** — the MHD epoch fix, the `isError` flag, and a partial `llms.txt` recount. The session-isolation blocker is *touched but not closed*, and arguably reads broader now. Server still negotiates `2024-11-05`; still 9 tools, same names.

**Fixed ✅**

| # | Finding | Before | Now (re-probe) |
|---|---|---|---|
| §3.1 | Rescue-food MHD dates | every item `1970-01-0X` (epoch) | real near-future: `rescue-001` → `2026-06-08`, `rescue-002` → `2026-06-07` (today is 2026-06-06). The most-quotable bug is **gone**. |
| §2.2 | `isError` on tool failures | always `isError:false` | now `isError:true` on **both** unknown-tool (`checkout`) and missing-required-arg (`cart_add` sans `product_sid`). Protocol-level `-32601` still correct. |
| §3.3 | `llms.txt` product count | "über 40 … Produkte" | now "**150+** Produkte" — narrowed, **not closed**: a broad sweep still unions **≥381 distinct sids**, so ~2.5× undercount (was ~10×). |

**Still open 🟠/⚪ (unchanged)**

- **§3.2 `unitOfMeasure`** — weight/volume-named items still tagged `Stück`: `Bio-Karotten 2 kg` (art-80001), `Karotten bunt 500g` (art-60178), `Mini-Karotten 200g` (art-60179), `Karottensaft 1l` (art-51037). Only the bare `Bio-Karotten` (art-1001) and the rescue rows carry `kg`/`l`. Per-kg comparison still breaks.
- **§3.4 Karottenkuchen dup** — both rows persist: `art-61033` "Karottenkuchen Stück" @ **2,99 €** and `art-40051` "Karottenkuchen-Stück" @ **3,29 €**.

**§2.1 session isolation 🔴 — touched, not closed; reads *broader* now**

What changed: `mcp-session-id` (the spec header *name*) was added to the accepted CORS set:
```
access-control-allow-headers: Content-Type, mcp-session-id, x-session-id, x-mcp-session
   # was: Content-Type, x-session-id, x-mcp-session
```
But the spec flow is still **not** bootstrapped, and cart round-trips are independently unreliable. Three observations, n=15 each, all read-only:

1. **No `Mcp-Session-Id` issued on `initialize`** (re-confirmed) — a standard `npx mcp-remote` client is given no session id to echo, so it sends none and lands on the header-less default bucket.
2. **`cart_add` always succeeds** — raw response carries `"success": true`, `"cart_total_items": 1` (confirmed verbatim, both modes). So a lost cart is *not* a failed add.
3. **Round-trips are non-durable even *with* a session header.** Same-session `cart_add` → immediate `cart_get` (fresh random `mcp-session-id` per trial): cart **lost in 11 of 15 trials** (`item_count:0`). Reproducible bare case:
   ```bash
   curl ... -H 'mcp-session-id: DBG-1' -d '{...cart_add art-1001 qty 1...}'  # success:true, cart_total_items:1
   curl ... -H 'mcp-session-id: DBG-1' -d '{...cart_get...}'                  # item_count:0, items:[]
   ```
   The header-less path, by contrast, returns a non-empty cart consistently — because every client without a session id shares **one** default bucket (the original collision risk, not a fix).

**Therefore (cause labeled inferred, per the original's discipline — not pinnable from one egress IP):** the blocker stands and is arguably wider than first written. It's no longer just "isolation absent for standard clients" — it's now "**either you send no session id and share a single global cart, or you send one and the cart isn't durable across the very next request.**" The observed loss rate is the load-bearing fact; the underlying cause (no durable session store behind the serverless endpoint) is inferred. Punch-list item #4 — adopt the spec `Mcp-Session-Id` flow *and* back it with durable per-session storage — remains the one real blocker; the three same-day fixes were the cheap ones.

**Net:** the demo got materially better within hours — three of four forwardable items closed, one (`llms.txt`) half-closed. The standing through-line is unchanged: this is still the showcase, and the one gap that's load-bearing for a *productized* tenant (durable, spec-bootstrapped sessions) is the one still open.

---

## Sources & method

- **All findings reproducible** via the curl/JSON-RPC requests shown inline (read-only; no DB writes — consistent with prior KT-adjacent rounds, no `a11y.py`/`cwv.py` invoked).
- **Endpoint:** `https://fabular.pages.dev/api/mcp` (POST JSON-RPC 2.0); landing `…/mcp`; `…/llms.txt`. Probed 2026-06-06.
- **MCP spec citations** verified against `modelcontextprotocol/modelcontextprotocol` (current revision 2025-11-25): `Mcp-Session-Id` (Streamable-HTTP transport), `isError` tool-result flag + SEP-1303 (input-validation-errors-as-tool-execution-errors).
- **Catalog lower bound (≥421):** distinct `sid` union across ~19 broad `product_search` queries (`limit:50`), deduped.
- **Lineage:** [`2026-06-06b-fabular-round2.md`](2026-06-06b-fabular-round2.md) · [`2026-06-06-fabular-vs-alfies-gurkerl-mechanics.md`](2026-06-06-fabular-vs-alfies-gurkerl-mechanics.md) · roster JS-wall context: [`2026-06-05-seo-geo-ai-readiness.md`](2026-06-05-seo-geo-ai-readiness.md).
- **Through-line:** demo-not-tenant; gaps framed as productization-readiness, not gotchas. Credit-first posture, per the prior fabular rounds.
