Skip to main content
Glama
Elegant-Lift-Sweden-AB

Lift-Spares Parts Finder

Official
README.md
# Lift-Spares Parts Finder (MCP server)

A public [MCP](https://modelcontextprotocol.io) server from [Lift-Spares](https://lift-spares.se) (Elegant Lift
Sweden AB). Add it to the AI assistant you already use and it identifies lift (elevator) spare parts in the
Lift-Spares catalog: by part number, description, make, model or part type, in any language. Ordering happens
on the website; the server hands you the product page and a cart link. Parts that are in the catalog but not
on the website, or not in the catalog at all, can be requested from the same chat.

**Endpoint:** `https://mcp.lift-spares.se/mcp` (streamable HTTP).

## Sign in

When you add the server, your assistant opens a sign-in page: sign in with your **Lift-Spares customer
account** (the same e-mail + one-time code as on lift-spares.se). No API keys. If you do not have an account,
create one at https://lift-spares.se/account first.

## Add it to your assistant

| Assistant | How |
|---|---|
| Claude.ai / Claude desktop | Settings → Connectors → *Add custom connector* → URL `https://mcp.lift-spares.se/mcp` → sign in. |
| Claude Code | `claude mcp add --transport http lift-spares https://mcp.lift-spares.se/mcp`, then `/mcp` to sign in. |
| ChatGPT (developer mode) | Settings → Connectors → *Create* → MCP server URL `https://mcp.lift-spares.se/mcp`, authentication OAuth. *Untested at the time of writing.* |
| Cursor | Settings → MCP → *Add new global MCP server*: `{"mcpServers": {"lift-spares": {"url": "https://mcp.lift-spares.se/mcp"}}}`, then sign in from the MCP settings. |

Any other MCP client that supports streamable HTTP with OAuth (dynamic client registration) works the same way.

## The three tools

### `liftspares_search_parts(query, max_rows=10)`
Search by the customer's own words in any language. Word search and meaning search run together; an exact part
number comes first with at most five possible alternatives.

```
> search KM587123G01
items: [{product_uid: "P142409", manufacturer: "KONE", title: "KONE door roller ...", sku: "KM587123G01",
         listing: "shop", storefront_url: "https://lift-spares.se/en/products/...", cart_url: "https://lift-spares.se/cart/<variant>:1",
         found_by: "words", relevance: 0.97, matched_in: "KM587123G01 in own part number"}, ...]
count: 3, total_word_matches: 3, note: null, next_step: "..."
```

- `listing: "shop"` — on the website; `storefront_url` is the product page, `cart_url` adds one to the cart.
- `listing: "catalog"` — in the Lift-Spares database but not on the website: requestable with
  `liftspares_request_part` and its `product_uid`.
- `total_word_matches` larger than `count` means the list was cut: search again with one more word.
- No prices and no stock levels, by design: they are on the product page.

### `liftspares_get_part(product_uid)`
One part's full identification row: all titles, all identifiers, barcode, vendor, type, tags, description,
HS code, weight, country of origin, listing and links. Unknown `product_uid` is an error.

### `liftspares_request_part(description, product_uid?, manufacturer?, part_number?, quantity?, searched?)`
Ask Lift-Spares for a part that is not on the website. The request is recorded for the Lift-Spares team and you
get a reference (`D-123`). No order is placed and no reply arrives in the chat; Lift-Spares reads the requests and
adds parts to the website.

## Ordering

The server never orders. It gives you the product page (`storefront_url`) and Shopify's cart permalink
(`cart_url`, `https://lift-spares.se/cart/<variant>:1`); price, availability and checkout are on lift-spares.se.
Agents that speak Shopify's Universal Commerce Protocol can also use the store's own MCP endpoint,
`https://lift-spares.se/api/ucp/mcp` (catalog, cart, checkout), which this server does not wrap.

## Privacy

Every search, lookup and request is recorded together with the signed-in customer's id, the client name and
version, the MCP session id and the user agent, to improve the catalog: parts people look for and Lift-Spares does
not carry are what gets added next. No e-mail address, name or IP address is stored with the lookups. Traces may
be kept in Lift-Spares' own Langfuse instance for the same purpose. Contact: https://lift-spares.se/pages/contact.

## Self-hosting

The code is small on purpose: `server.py` (three tools), `hybrid.py` (the search: word search + meaning search +
reranker, the same file the Lift-Spares chat uses), `auth.py` (Cloudflare Access JWT), `catalog.py` (Postgres),
`demand.py` (request/lookup recording), `trace.py` (Langfuse, optional).

```
uv sync --frozen
cp deploy/service.env.example service.env   # fill in
uv run python server.py
```

Environment (see `deploy/service.env.example`):

| Variable | Meaning |
|---|---|
| `MCP_HOST`, `MCP_PORT` | bind address (default `127.0.0.1:8769`); `MCP_HOST:MCP_PORT`, `mcp.lift-spares.se` and `127.0.0.1:*` are the accepted `Host` headers |
| `DATABASE_URI` | Postgres DSN of the read-only role (see *Database contract*) |
| `ACCESS_TEAM_DOMAIN` | Cloudflare Access team domain, e.g. `lift-spares.cloudflareaccess.com` (JWT issuer and key source) |
| `ACCESS_AUD` | the Access application's AUD tag (JWT audience) |
| `ACCESS_JWKS_URL` | optional override of `https://<team>/cdn-cgi/access/certs` (tests) |
| `CLAIM_CUSTOMER_ID` | dotted path to the customer id in the JWT claims, default `custom.sub` (Access copies identity-provider claims into `custom`); falls back to the Access `sub` |
| `MCP_CUSTOMER_RPM` | lookups per customer per minute; above it the next call is refused for a minute (default 60) |
| `LITELLM_BASE_URL`, `LITELLM_SEARCH_KEY` | the LiteLLM proxy serving the embedding and rerank models |
| `CHATKIT_SEARCH_EMBED_MODEL`, `CHATKIT_SEARCH_RERANK_MODEL` | model names on that proxy (empty rerank model = no rerank stage) |
| `LANGFUSE_BASE_URL`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_TRACING_ENVIRONMENT` | optional tracing; unset keys = no tracing |

Authentication is not in this code: Cloudflare Access (Managed OAuth) in front of the host signs the customer in
with the Shopify customer account (generic OIDC identity provider), serves the OAuth discovery and the 401 for
anonymous clients, and forwards `Cf-Access-Jwt-Assertion` to the origin. `auth.py` validates that JWT (RS256,
JWKS, `aud`, `iss`) on every request and refuses everything else. A systemd unit is in `deploy/`.

### Database contract

The server needs one login role with `SELECT` on the view `chatkit_public.product` (price-free: `product_uid,
manufacturer, shop_title, canonical_title, sku, primary_code, all_titles, all_identifiers, barcode, vendor,
product_type, tags, description_html, hs_code, weight, country_of_origin, featured_image_url, storefront_url,
shopify_status, shopify_variant_id`) and `EXECUTE` on:

- `chatkit_public.find(words text, max_rows int)` — the word search (`product_uid, manufacturer, canonical_title,
  primary_code, sku, all_identifiers, product_type, shopify_status, storefront_url, words_matched, words_searched,
  matched_in, score, total_matches`);
- `chatkit_public.find_similar(query_vec halfvec, max_rows int)` — the meaning search, same columns;
- `demand.record(jsonb) RETURNS (id bigint, recent_count int)` — inserts one lookup row and returns the caller's
  lookups in the last 60 s (the rate limiter). It refuses payloads carrying personal-data keys.

Statement timeouts are a role setting (`ALTER ROLE ... SET statement_timeout`), not code.

### Tests

```
uv run pytest
```

Unit tests only: an in-test RS256 key pair stands in for Cloudflare Access, fakes stand in for Postgres and
LiteLLM; `tests/test_no_secrets.py` guards the tree against committed keys.

## License

Apache-2.0, Copyright 2026 Elegant Lift Sweden AB.