Skip to main content
Glama
ai-coffeencode

Lumenco Catalog MCP Server

README.md
# Lumenco Catalog (Phase 1 scraper + Phase 2 MCP)

This repository has two layers:

1. **Phase 1** scrapes `https://en.staging.lumenco.ca/` into PostgreSQL.
2. **Phase 2** exposes that catalog through a **read-only** Model Context Protocol server so Claude can retrieve products, specifications, listings, and recommendation *candidates* without browsing Lumenco.

```
CLAUDE
  │ MCP / HTTPS
  ▼
Lumenco Product Database (Streamable HTTP)
  │ tools → services → repositories
  ▼
PostgreSQL  (Phase 1 catalog)
```

**Phase 1 scrapes. Phase 2 exposes. Claude reasons.**

The MCP server never scrapes Lumenco, never downloads specification PDFs, never calls an LLM, and never writes to the database.

## What the site looks like

Lumenco staging is a Magento 2 storefront.

| Area | Behaviour |
| --- | --- |
| Brands | `https://en.staging.lumenco.ca/brand` lists every brand (Amasty Brands). Brand cards on that page often point at `staging.lumenco.ca`; the scraper rewrites them to the English host. |
| Brand listings | `https://en.staging.lumenco.ca/brand/{slug}` with Magento pagination `?p=2` (24 products per page). Total page count is in `#am-page-count`. |
| Products | Canonical URLs such as `/aaled-aa-900018-1x4-bl.html`. Server-rendered HTML includes JSON-LD, SKU, price, stock, spec table, and a **Specification Sheet** link. |
| Spec sheets | Usually same-origin PDFs under `/dev/*.pdf`. |
| Sitemap | `/sitemap.xml` currently errors with HTTP 500. The crawler still tries known sitemap paths, then falls back to brand + category discovery. |
| GraphQL | `/graphql` exists but the staging schema is broken (`Config element "String" is not declared`). HTML crawling is the reliable source. |
| Fetching | Product pages are server-rendered. Scrapling's HTTP `FetcherSession` is the default. `AsyncDynamicSession` is registered as a lazy fallback if a product page is missing required fields. |

The crawler stays on `en.staging.lumenco.ca`. External Specification Sheet PDFs may be downloaded as product documents. Ads, analytics, cart, checkout, and social URLs are ignored.

`robots.txt` is written for public search engines (`User-agent: *` disallows most paths except `/brand` and a few CMS pages). This scraper is an authorized catalog ingest against staging, so `ROBOTS_TXT_OBEY` defaults to `false`. Set it to `true` if you want Scrapling to honour that file.

## Project layout

```
scraper/                    Phase 1 Scrapling crawler
  config.py
  spider.py
  discovery.py
  fetcher.py
  cli.py
  selectors/
  parsers/
  pipelines/
  database/                 shared SQLAlchemy models + repositories
  utils/
app/                        Phase 2 read-only MCP server
  server.py                 Streamable HTTP + /health
  config.py
  auth/middleware.py        bearer token (replaceable with OAuth)
  tools/                    MCP tool layer
  services/                 catalog / product / search / recommendations
  repositories/             read-only queries over Phase 1 tables
  schemas/
  database/session.py       pooled, read-only sessions
alembic/                    PostgreSQL migrations
tests/
scripts/create_readonly_user.sql
```

## 1. Install dependencies

Python 3.10+ is required.

```bash
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS / Linux
source .venv/bin/activate

pip install -r requirements.txt
```

Scrapling's HTTP/browser extras are included via `scrapling[fetchers]`. If you need browser fallback (DynamicFetcher), install browser binaries:

```bash
scrapling install
```

Optional OCR for scanned/image-only specification PDFs:

```bash
pip install pytesseract Pillow
# plus a Tesseract OCR engine on the host
```

OCR is off by default (`ENABLE_OCR=false`). Image-based PDFs are stored and marked `ocr_required` rather than saved as empty text.

## 2. Configure PostgreSQL

The fastest local setup:

```bash
docker compose up -d postgres
```

That starts PostgreSQL 16 with:

- user: `lumenco`
- password: `lumenco`
- database: `lumenco`
- host port: `5433` (container port stays `5432`; 5433 avoids a Windows PostgreSQL install already using 5432)

Copy environment config:

```bash
copy .env.example .env   # Windows
cp .env.example .env     # macOS / Linux
```

Default connection string:

```
DATABASE_URL=postgresql+psycopg2://lumenco:lumenco@127.0.0.1:5433/lumenco
LUMENCO_BASE_URL=https://en.staging.lumenco.ca/
```

Create tables (either approach works):

```bash
python -m scraper init-db
python -m alembic upgrade head
```

## 3. Run a 5-product test crawl

```bash
python -m scraper crawl --limit 5
```

This discovers products from the live site, processes only the first 5, downloads their Specification Sheets, stores rows in PostgreSQL, and prints a crawl report.

You can also pin a brand:

```bash
python -m scraper crawl --limit 5 --url https://en.staging.lumenco.ca/brand/aaled
```

Or a single product:

```bash
python -m scraper crawl --url https://en.staging.lumenco.ca/aaled-aa-900018-1x4-bl.html
```

## 4. Run the complete crawl

```bash
python -m scraper crawl
```

This walks all brands (and category listings), follows every pagination page, and scrapes every discoverable product. Do not confuse `--limit` with a production catalog cap — `--limit` is development-only.

Rate limiting is built in: concurrency, per-domain caps, download delay, retries with exponential backoff, and optional AutoThrottle. Tune them in `.env`:

```
MAX_CONCURRENCY=5
CONCURRENT_REQUESTS_PER_DOMAIN=3
DOWNLOAD_DELAY=0.5
RETRY_COUNT=3
AUTOTHROTTLE_ENABLED=true
```

## 5. Resume a crawl

Scrapling checkpointing is enabled via `CRAWL_DIR` (default `./data/crawl`). Press Ctrl+C once for a graceful pause. Run again with:

```bash
python -m scraper crawl --resume
```

Resume behaviour:

- Scrapling restores pending requests from `CRAWL_DIR`.
- Products already stored with `scrape_status=success` are skipped unless you pass `--force`.
- Failed products are retried.
- Specification PDFs are not re-extracted when the document hash is unchanged.

## 6. Inspect the database

```bash
python -m scraper stats
python -m scraper validate
python -m scraper product --sku aa-900018-1x4-bl
```

Or with `psql`:

```bash
psql postgresql://lumenco:lumenco@127.0.0.1:5433/lumenco
```

Useful queries:

```sql
SELECT count(*) FROM products;
SELECT sku, product_name, price, brand FROM products ORDER BY last_scraped_at DESC LIMIT 20;

SELECT p.sku, d.filename, d.extraction_status, left(d.extracted_text, 200)
FROM specification_documents d
JOIN products p ON p.id = d.product_id
WHERE d.extraction_status = 'extracted'
LIMIT 10;
```

## 7. How Specification Sheets are processed

For every product page the parser looks for:

- `a.document-item-link` (Lumenco's "Specification Sheet" control)
- Equivalent labels: Specification Sheet, Spec Sheet, Specifications, Technical Data, PDF, Fiche technique, etc.

Then the pipeline:

1. Stores the document URL.
2. Downloads the file with `httpx` (not a browser).
3. Validates PDF magic bytes (`%PDF`).
4. Saves a deterministic copy: `data/specifications/{sku}_{hash16}.pdf`.
5. Extracts text with PyMuPDF.
6. Cleans whitespace while keeping page/section breaks.
7. Stores extracted text, SHA-256 hash, method, and status.
8. Parses `Label: Value` lines from the PDF **without inventing fields**.
9. Merges PDF specs with product-page specs, preserving source:

```json
{
  "Voltage": {
    "value": "120-277V",
    "source": "product_page",
    "raw": "120-277V",
    "normalized": {"min": 120, "max": 277, "unit": "V"}
  }
}
```

If a PDF has little or no text, status is `ocr_required` (or OCR is attempted when `ENABLE_OCR=true`). Empty successful extractions are not silently stored.

Unchanged PDFs are skipped on later crawls by content hash.

## 8. Troubleshooting failed products

| Symptom | What to do |
| --- | --- |
| `python -m scraper validate` reports issues | Read the JSON `issues` list (`missing_name`, `invalid_url`, `empty_extracted_text`, …). |
| Product failed HTTP 5xx / timeout | Re-run `python -m scraper crawl --resume`. Failures are in `crawl_errors`. |
| Missing Specification Sheet | Expected for some SKUs. Status is `not_found`; the product row is still stored. |
| PDF marked `ocr_required` | Enable OCR extras or inspect the saved file under `data/specifications/`. |
| PDF marked `invalid_pdf` | The linked file was not a PDF (HTML error page, etc.). Check `specification_documents.error_message`. |
| Duplicate products | Should not happen: unique `product_url` / `canonical_url` / `sku` plus upsert. Run `validate`. |
| Brand pages look empty | Confirm you are on `en.staging.lumenco.ca`, not the French host. The spider rewrites this automatically. |
| DynamicFetcher errors | Run `scrapling install`. HTTP fetching is enough for current staging HTML. |
| Database connection errors | Check `DATABASE_URL`, `docker compose ps`, and `python -m scraper init-db`. |

Structured logs look like:

```
[INFO] PRODUCT_FETCH url=https://en.staging.lumenco.ca/aaled-aa-900018-1x4-bl.html sku=aa-900018-1x4-bl status=success
[INFO] SPEC_SHEET sku=aa-900018-1x4-bl status=extracted duration=0.84s
[ERROR] SPEC_SHEET sku=... status=failed error=...
```

## Tests

```bash
pytest
```

Coverage includes URL normalization, product/SKU/price parsing, spec-sheet detection, PDF extraction, database upsert / duplicate prevention, listing membership order, recommendation scoring, and MCP tool integration.

## CLI reference

```bash
python -m scraper crawl --limit 100
python -m scraper crawl --mode development --limit 100 --url https://en.staging.lumenco.ca/brand/aaled
python -m scraper crawl --resume
python -m scraper reprocess-specs
python -m scraper embeddings --limit 100
python -m scraper embedding-stats
python -m scraper recommend --sku ABC123 --type related --limit 5
python -m scraper recommendation-eval
python -m scraper validate
python -m scraper stats
python -m scraper sample
python -m scraper product --sku ABC123
python -m scraper init-db
python -m app.server
```

Default crawl mode is **development**: at most 100 successfully processed products, brands only (no category walk). A full-catalog crawl is refused unless you pass `--mode full --limit N` or `--mode full --confirm-full`.

# Phase 2.5 — 100-product data quality

This project currently targets a **controlled ~100-product Lumenco dataset**. The live catalog has 30,000+ SKUs; full-catalog crawling is intentionally out of scope.

## Pipeline

Scrapling → product extraction → PDF download → PDF text or OCR → specification normalization → PostgreSQL → read-only MCP

PDF text extraction is attempted first. OCR (Tesseract via pytesseract) runs only when the PDF has no meaningful text. Set `ENABLE_OCR=true` and install Tesseract plus `pip install pytesseract Pillow`.

Normalized specifications keep source and conflict flags. Raw spec-sheet text is stored on `specification_documents.extracted_text`. MCP `get_product` returns compact structured specs; `get_product_specifications` can include raw text when `include_raw_text=true`.

French Magento category URLs (for example `/eclairage-interieur` and `/electricite`) still appear in the shared header on the English host. They 404 there. The crawler **does not enqueue** those paths. New crawls also use an isolated Scrapling checkpoint directory (`data/crawl/run-<id>`) so an old pause file cannot resume thousands of category URLs. Use `--resume` only to continue the shared `data/crawl` checkpoint.

French Magento category URLs rewritten onto the English host are classified as `expected_404` and are not counted as product failures.

After a crawl:

```bash
python -m scraper stats
python -m scraper validate
python -m scraper sample
python -m scraper product --sku L0110TUT8002020
```

# Phase 3A — Vector search + product embeddings

Phase 3A adds semantic product representations with **PostgreSQL + pgvector**. It does **not** implement Related/Upsell/Cross-sell ranking (that is Phase 3B).

## Architecture

```
~100 product dataset
        ↓
Canonical product text (cleaned, no HTML)
        ↓
EmbeddingService (OpenAI-compatible API)
        ↓
product_embeddings (pgvector)
        ↓
VectorSearchService
        ↓
MCP tool: search_similar_products
```

## Setup

1. Use a Postgres image with pgvector (`docker-compose.yml` uses `pgvector/pgvector:pg16`).
2. Set embedding env vars in `.env` (see `.env.example`).
3. Migrate:

```bash
python -m alembic upgrade head
```

4. Generate embeddings for the development catalog:

```bash
python -m scraper embeddings --limit 100
python -m scraper embedding-stats
```

Unchanged products are skipped via `content_hash`. Use `--force` to regenerate everything.

### Index strategy

HNSW on cosine distance (`vector_cosine_ops`, `m=16`, `ef_construction=64`) — good for the ~100-product dataset and still usable as the catalog grows. IVFFlat can be considered later for much larger catalogs.

### MCP

New read-only tool: `search_similar_products`. It only reads stored vectors; it does not call the embedding API or scrape Lumenco. Existing recommendation tools are unchanged.


# Phase 3B — Hybrid recommendation engine

Recommendations combine **pgvector similarity** with **structured product rules**. Vector similarity alone is not enough: an 18W T8 tube, a 30W T8 tube, and a T8 fixture may all be semantically close, but they map to Related, Upsell, and Cross-sell respectively.

```
Product → vector candidates + structured neighbors
                ↓
        hard exclusions
                ↓
   Related / Upsell / Cross-sell scorers
                ↓
     scores + confidence + reasons → MCP
```

| Type | Meaning |
|------|---------|
| **Related** | Similar use case / category / specs |
| **Upsell** | Same family **and** measurable improvement (not price alone) |
| **Cross-sell** | Complementary (driver, trim, housing, fixture↔tube) |

No LLM is used inside ranking. MCP tools `find_related_products`, `find_upsell_products`, and `find_cross_sell_products` call `RecommendationService` (read-only).

### CLI

```bash
python -m scraper recommend --sku L0110TUT8002020 --type related --limit 5
python -m scraper recommend --sku L0110TUT8002020 --type upsell --limit 5 --debug
python -m scraper recommend --sku L0110TUT8002020 --type cross-sell --limit 5
python -m scraper recommendation-eval --sample-size 10 --limit 3
```

Weights are configurable via env vars such as `RELATED_VECTOR_WEIGHT`, `UPSELL_TECHNICAL_WEIGHT`, `CROSS_SELL_COMPATIBILITY_WEIGHT` (see `.env.example`).


# Phase 3C — Claude + MCP workflow

```
User → Claude → MCP (/mcp) → PostgreSQL + pgvector + RecommendationService → Claude → User
```

**Responsibilities**

| Layer | Does |
|-------|------|
| Scrapling | Crawl / store |
| PostgreSQL + pgvector | Source of truth + vectors |
| RecommendationService | Deterministic Related/Upsell/Cross-sell ranking |
| MCP | Read-only retrieval (no scrape, no writes, no LLM) |
| Claude | Conversation, tool selection, explanation |

### Claude skill

Project skill: `.cursor/skills/lumenco-product-mcp/SKILL.md`

### End-to-end prompts

See [`docs/claude-e2e-tests.md`](docs/claude-e2e-tests.md).

### Connect Claude / Inspector

1. `docker compose up -d postgres`
2. `python -m app.server`
3. Point the client at `http://localhost:8000/mcp` (Streamable HTTP)
4. Optional: `MCP_AUTH_TOKEN` + `Authorization: Bearer …`

For remote deployment later: expose only the MCP HTTPS endpoint; keep PostgreSQL private.

### Development dataset

Current catalog: **~100 products**. Full Lumenco catalog (30k+) is intentionally out of scope.


# Phase 2 — Lumenco Product Database MCP

Read-only Streamable HTTP MCP server named **Lumenco Product Database**.

## Architecture

```
Claude
  │ MCP / Streamable HTTP
  ▼
Lumenco MCP Server   (/mcp, /health)
  │
  ▼
MCP Tool Layer
  │
  ▼
Service Layer          catalog / product / search / similarity / recommendation
  │
  ▼
Repository Layer       SQLAlchemy, no raw SQL in tools
  │
  ▼
PostgreSQL + pgvector  products, specs, listings, product_embeddings
```

## Local setup

1. Complete Phase 1 setup (PostgreSQL + `.env` + `python -m alembic upgrade head`).
2. Run a crawl so the catalog is populated.
3. Install MCP extras if they are not already in `requirements.txt`:

```bash
pip install -r requirements.txt
```

4. Set MCP variables in `.env`:

```
MCP_HOST=0.0.0.0
MCP_PORT=8000
MCP_AUTH_TOKEN=replace-with-a-long-random-token
DATABASE_URL=postgresql+psycopg2://lumenco:lumenco@127.0.0.1:5433/lumenco
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_POOL_TIMEOUT=30
```

For production, create a SELECT-only role:

```bash
psql postgresql://lumenco:lumenco@127.0.0.1:5433/lumenco -f scripts/create_readonly_user.sql
```

Then point `DATABASE_URL` at `lumenco_mcp`.

## Running

```bash
python -m app.server
```

Or:

```bash
uvicorn app.server:app --host 0.0.0.0 --port 8000
```

Docker:

```bash
docker compose up --build mcp
```

### MCP endpoint

`http://localhost:8000/mcp`

### Health

`GET http://localhost:8000/health`

```json
{
  "status": "ok",
  "service": "lumenco-product-mcp",
  "database": "connected"
}
```

## MCP Inspector

```bash
npx -y @modelcontextprotocol/inspector
```

Connect to `http://localhost:8000/mcp` with transport **Streamable HTTP**. If `MCP_AUTH_TOKEN` is set, add:

```
Authorization: Bearer <token>
```

Confirm all eight tools are listed and executable.

## Available tools

All tools read PostgreSQL only. None fetch Lumenco URLs.

### `get_catalog_status`

Catalog size and latest crawl freshness. No input.

### `get_listing_products`

Products on a brand/category listing URL, in original listing position.

| Input | Required | Notes |
| --- | --- | --- |
| `listing_url` | yes | Normalized and used as a database key |
| `limit` | no | Default 20, max 100 |
| `offset` | no | Default 0 |

### `get_product`

Complete product record by `product_id` and/or `sku`.

### `get_product_specifications`

Structured specs plus stored Specification Sheet text. Does not download PDFs.

### `search_products`

Local catalog search (SKU, name, brand, category, description, specifications).

Optional filters: `brand`, `category`, `subcategory`, `sku`, `min_price`, `max_price`.

### `search_similar_products`

Semantic neighbors from stored pgvector embeddings (cosine similarity). Does not generate embeddings or call an LLM.

Optional filters: `brand`, `category`, `subcategory`, `min_price`, `max_price`.

### `find_related_products`

Hybrid **Related** candidates (vector + category/application/specs). Includes `match_score`, `confidence`, `score_breakdown`, and `match_reasons`. Optional `debug=true`.

### `find_upsell_products`

Hybrid **Upsell** candidates. Requires measurable improvement (not price alone). Reasons in `upgrade_reasons`.

### `find_cross_sell_products`

Hybrid **Cross-sell** candidates. Compatibility dominates; same-family alternatives are excluded.

Recommendation tools exclude the source product and de-duplicate candidates. Claude should request a candidate pool, then choose the final 3 Related / 4 Upsell / 7 Cross-sell itself.

## Example workflow

User: analyze the first 10 products from `https://en.staging.lumenco.ca/brand/aaled` and give 3 Related, 4 Upsell, 7 Cross-sell.

1. `get_listing_products(listing_url=..., limit=10)`
2. `get_product(product_id=...)` for each source
3. `find_related_products` / `find_upsell_products` / `find_cross_sell_products` with `limit=10`
4. Claude selects the final set from the candidate pools

## Production deployment

Expose only the MCP HTTPS endpoint. Keep PostgreSQL private.

```
Internet → HTTPS → MCP server → private PostgreSQL
```

Suitable hosts: Railway, Render, Google Cloud Run, AWS, Cloudflare.

Requirements:

- HTTPS terminator in front of `uvicorn` / the Docker image
- `MCP_AUTH_TOKEN` set (bearer middleware is isolated so OAuth can replace it later)
- read-only `DATABASE_URL`
- health check on `/health`

Do not publish port 5432.

## Claude custom connector

After the server is reachable at a public HTTPS URL:

1. In Claude, add a custom connector.
2. MCP URL: `https://your-host/mcp`
3. Server name should appear as **Lumenco Product Database**.
4. Configure bearer authentication with `MCP_AUTH_TOKEN`, or OAuth if you replace the middleware.
5. Ask: "How many products are currently in the Lumenco database?" Claude should call `get_catalog_status`.

Temporary public HTTPS for local testing: Cloudflare Tunnel, ngrok, or similar in front of `localhost:8000`.

## Security

- No `execute_sql`, `fetch_url`, `run_command`, or crawl tools
- SQLAlchemy parameterized queries only
- Query limits enforced
- Sessions open `SET TRANSACTION READ ONLY` on PostgreSQL
- Secrets are not returned in tool errors