Skip to main content
Glama
README.md
# mcp_feast

An MCP server over a [Feast](https://feast.dev) feature store, for a card-swipe
fraud model. Runs entirely locally: Parquet offline store, SQLite online store,
no cloud, no broker.

---

# System design

## The four layers

```mermaid
flowchart TB
    subgraph H["HOST — decides which tools to call"]
        direction LR
        H1["host.py<br/><i>local LLM, qwen2.5:7b</i>"]
        H2["Claude Code<br/><i>.mcp.json</i>"]
        H3["mcp_cli.py<br/><i>manual, for testing</i>"]
    end

    subgraph M["MCP SERVER — no Feast import, no credentials"]
        M1["12 read tools<br/>+ 2 gated write tools"]
    end

    subgraph A["FEATURE API — holds the Feast SDK"]
        A1["catalog"]
        A2["lineage"]
        A3["health"]
        A4["values"]
    end

    subgraph S["STORAGE"]
        direction LR
        S1[("registry.db<br/><i>metadata</i>")]
        S2[("online_store.db<br/><i>SQLite, serving</i>")]
        S3[("data/*.parquet<br/><i>offline</i>")]
    end

    H1 -->|"stdio"| M1
    H2 -->|"stdio"| M1
    H3 -->|"stdio"| M1
    M1 ==>|"HTTP / JSON"| A1
    A1 -->|"Feast SDK"| S1
    A2 --> S1
    A3 --> S3
    A4 --> S2
```

**That thick arrow is the whole design.** Everything Feast-specific lives below
it. The MCP server above it needs no Feast install, no store drivers, and no
warehouse credentials — it is an HTTP client and nothing more.

That buys three things. Swapping SQLite for Redis becomes a `feature_store.yaml`
change the MCP layer never sees. A laptop running the MCP server needs one
reachable URL instead of a network route to production Redis. And the same API
can serve a second consumer — a model server — that was never built here but
would call `POST /features/online` exactly as the MCP layer does.

## What actually runs

| Process | Started by | Holds | Port |
|---|---|---|---|
| Feature API | `./run_api.sh` | the `FeatureStore` singleton | 8000 |
| MCP server | the host, over stdio | an `httpx` client | — |
| Ollama | `ollama serve` | qwen2.5:7b | 11434 |
| Host | `python3 host.py` | the conversation loop | — |

Only the API imports Feast. Verify it:

```bash
python3 -c "import mcp_server.server, sys; print('feast' in sys.modules)"   # False
```

## One request, end to end

Asking *"why would card C-4471 be flagged?"* crosses every layer twice:

```mermaid
sequenceDiagram
    autonumber
    participant L as Model
    participant M as MCP server
    participant A as Feature API
    participant F as Feast SDK
    participant D as SQLite

    L->>M: resolve_card("C-4471")
    M->>A: GET /cards/C-4471
    A-->>M: CU-8842
    M-->>L: C-4471 is owned by CU-8842

    Note over L: the model spans two entities,<br/>so both join keys are needed

    L->>M: explain_features_for_entity(card + customer)
    M->>A: POST /features/explain
    A->>F: get_online_features(fraud_model_v2)
    F->>D: read 7 values
    A->>F: provider.online_read(...)
    F->>D: read per-entity event_ts
    Note over A: joins values against TTL<br/>to classify each feature
    A-->>M: values + age + is_stale + reasons
    M-->>L: FRESH 6 / STALE 0 / MISSING 1
```

That second SDK call is the part Feast does not give you for free — see below.

## How data reaches the online store

```mermaid
flowchart LR
    P[("data/*.parquet<br/>offline store")]
    O[("online_store.db<br/>online store")]
    W["live swipe"]
    R["serving<br/><i>milliseconds</i>"]
    T["training set"]

    P -->|"feast materialize — batch, scheduled"| O
    W -->|"feast push — real time, no broker"| O
    O -->|"get_online_features"| R
    P -.->|"get_historical_features — not exposed"| T
```

The dashed path is the training half of a feature store. It is left out on
purpose: it runs a minutes-long query returning millions of rows, which is the
wrong shape for a chat tool. That is also why the generator writes **no fraud
labels**.

## Why the API is not a passthrough

`get_online_features()` returns values and nothing else. A bare `null` cannot
tell you which of four situations you are in — and Feast serves an expired
value without complaint:

```mermaid
flowchart LR
    B["get_online_features<br/><b>txn_count_1h: null</b>"]
    B --> C1["<b>ENTITY_NOT_FOUND</b><br/>no row for this card"]
    B --> C2["<b>NULL_IN_SOURCE</b><br/>feature genuinely absent"]
    B --> C3["<b>STALE</b><br/>6h58m old, TTL is 2h"]
    B --> C4["<b>a real zero</b><br/>the card had no swipes"]
```

`POST /features/explain` separates them by recovering the per-entity
`event_timestamp` through the provider's `online_read` — the same call
`get_online_features` makes internally, but one that surfaces the timestamp —
and joining it against the view's TTL.

Three facts the raw SDK will not give you:

| Endpoint | Derives |
|---|---|
| `/features/explain` | per-feature freshness and missing-value reason |
| `/features/{view}/{feature}/lineage` | source → view → consuming services |
| `/feature-views/{name}/consumers` | blast radius before a change |

## The trap this is built to avoid

Freshness is **per entity**, not per view. Both are real questions with
different answers, and confusing them is the most dangerous mistake available
here:

```mermaid
flowchart TB
    V["<b>card_velocity</b><br/>materialized 52 seconds ago<br/>check_feature_freshness reports OK"]
    V -->|"source had a row from 58m ago"| E1["<b>C-4471</b><br/>age 58m<br/>FRESH"]
    V -->|"source's newest row is 6h58m old"| E2["<b>C-7788</b><br/>age 6h58m<br/>STALE"]

    style E1 stroke:#2a9d4a,stroke-width:2px
    style E2 stroke:#d1443c,stroke-width:3px
```

Materialization writes whatever the source holds. For a card with no recent
rows that is an old value — so an entity can be hours stale inside a view that
materialized seconds ago. Refreshing the view cannot fix it; only a push can.

| Question | Tool | Scope |
|---|---|---|
| "Is a pipeline dead?" | `check_feature_freshness` | all entities |
| "Is *this card* current?" | `explain_features_for_entity` | one entity |

A small model reliably conflates these. What fixed it was not the system
prompt — it was appending the warning to `check_feature_freshness`'s **output**.
A model that skips a tool description still reads the result it just acted on.

## Tools map to endpoints one to one

```mermaid
flowchart LR
    T1["list_feature_views<br/>describe_feature_view<br/>list_feature_services<br/>search_features<br/>list_entities<br/>resolve_card"] --> E1["/entities · /data-sources<br/>/feature-views · /feature-services<br/>/features/search · /cards"]
    T2["get_feature_lineage<br/>get_feature_consumers"] --> E2["/features/../lineage<br/>/feature-views/../consumers"]
    T3["check_feature_freshness"] --> E3["/health/materialization"]
    T4["get_online_features<br/>explain_features_for_entity"] --> E4["/features/online<br/>/features/explain"]
    T5["push_swipe<br/>trigger_materialization"] -.->|"only when FEAST_MCP_READONLY=false"| E5["/features/push<br/>/feature-views/../materialize"]
```

`api/routers/` and `mcp_server/tools/` mirror each other file for file —
catalog, lineage, health, values — so navigation is obvious.

## Two ideas that carried the design

**Errors are written as instructions.** A 404 returns `Available: [...]`, and a
bad entity row names the join keys it needs. Observed repeatedly: a 7B model
gets it wrong, reads the error, and fixes itself on the next step rather than
guessing again.

**Guidance rides on output, not just descriptions.** Tool descriptions get
skipped; results do not. Both the freshness scope warning and
`trigger_materialization`'s "call check_feature_freshness to confirm" live in
the returned text, and both changed model behaviour when prompt wording alone
had failed.

---

## Quick start

Python **3.11 or 3.13** — both verified end to end (SDK reads, API, MCP tools,
`feast ui`). Feast declares `>=3.10`.

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```

### Every session starts with one command

```bash
./start.sh
```

Idempotent — every step is a no-op when already satisfied. It checks
dependencies, starts Ollama and pulls the model if needed, **regenerates the
mock data when it has aged past `card_velocity`'s 2h TTL**, starts the API if it
is down, and prints the state of the three demo entities:

```
python                 /opt/miniconda3/envs/myenv/bin/python3
ollama                 already running
model                  qwen2.5:7b present
data                   current (49m old)
api                    started on :8000
mode                   read-write

Demo entities:
  fraud    C-4471/CU-8842  fresh=6 stale=0 missing=1  velocity=48m22s
  stale    C-7788/CU-3310  fresh=4 stale=3 missing=0  velocity=6h48m
  unknown  C-9999/CU-1002  fresh=4 stale=0 missing=3  velocity=-
```

That data check is the one that matters. The mock data is anchored to
generation time, so a couple of hours after `setup.sh` every card reads stale
and the personas stop being distinguishable. `start.sh` catches it before you
notice.

```bash
./start.sh --fresh     # regenerate even if still current
./stop.sh              # stop the API
./stop.sh --all        # stop the API and Ollama
```

Then ask it things:

```bash
python3 host.py --trace "Is any feature pipeline stale or silently broken?"
./demo_host.sh                  # every verified prompt, read-only groups
./demo_host.sh catalog          # one group
./demo_host.sh all              # including writes
```

The lower-level entry points are still there if you want them:

```bash
./setup.sh        # data + apply + materialize
./run_api.sh      # API only, in the foreground
```

Both scripts honour a `PYTHON` override if the deps live elsewhere:

```bash
PYTHON=/opt/miniconda3/envs/myenv/bin/python3 ./setup.sh
```

The MCP server is launched by the host via `.mcp.json`, which pins an absolute
interpreter path for the reason in Troubleshooting below. `./run_mcp.sh` runs it
by hand for debugging.

### Troubleshooting: `No module named 'grpc_health'`

Raised by `feast ui` and `feast serve_registry`. Those two commands import
`grpc_health`, which plain `pip install feast` does not install — it sits behind
Feast's `[grpcio]` extra. `requirements.txt` pins `feast[grpcio]`, so a clean
install covers it; if you installed Feast some other way:

```bash
python3 -m pip install 'feast[grpcio]'
```

Nothing else in this project needs it, so `preflight.py` reports it as a warning
rather than an error.

### Troubleshooting: `No module named 'api'`

You are inside `feature_repo/`. The `api` package lives at the project root, so
`uvicorn api.main:app` only resolves from there. `./run_api.sh` `cd`s to the
project root itself, so it works from any directory — prefer it.

### Troubleshooting: wrong interpreter

Two symptoms, one cause -- a different Python than the one holding the deps:

```
ModuleNotFoundError: No module named 'feast'
ImportError: cannot import name 'MCPServer' from 'mcp.server'
```

The second is the sneakier one: `mcp` 1.x imports fine but exposes
`mcp.server.fastmcp.FastMCP`, not the 2.x `mcp.server.MCPServer` this project
uses. A shell prompt showing an active conda env is not proof -- check PATH:

```bash
which python3 && python3 -V
echo $PATH | tr ":" "\n" | head -3
```

If a framework or system Python sits ahead of your env, every `python3` call
escapes the env regardless of what the prompt says. Diagnose properly with:

```bash
python3 preflight.py
```

It imports the exact symbol each part of the code needs -- not just the module --
so a wrong-major dependency is caught by name, and it warns when `uvicorn` or
`feast` on your PATH belong to a different environment.

Every entry point takes a `PYTHON` override, so you never have to fight PATH:

```bash
PYTHON=/opt/miniconda3/envs/myenv/bin/python3 ./setup.sh
PYTHON=/opt/miniconda3/envs/myenv/bin/python3 ./run_api.sh
PYTHON=/opt/miniconda3/envs/myenv/bin/python3 python3 mcp_cli.py tools
```

Two rules avoid this entirely:

- Start the API with `./run_api.sh`, or `python3 -m uvicorn api.main:app`.
  Never bare `uvicorn api.main:app` — that resolves uvicorn from PATH, which may
  belong to a different Python than the one holding Feast, and the failure
  surfaces forty frames deep in an import chain.
- Keep `.mcp.json`'s `command` an absolute interpreter path. `"python3"` there
  resolves against whatever PATH the host process happened to have.

## What is in the registry

**Entities** — `card` (`card_id`), `customer` (`customer_id`)

**Feature views**

| View | Entity | Kind | Features | TTL |
|---|---|---|---|---|
| `card_velocity` | card | push | `txn_count_1h`, `txn_count_24h`, `amount_sum_1h` | 2h |
| `customer_profile` | customer | batch | `avg_amount_30d`, `distinct_merchants_30d`, `home_country`, `chargebacks_lifetime` | 7d |

**Feature service** — `fraud_model_v2`, binding all 7 features.

The 2h / 7d TTL split is deliberate: it makes the freshness tooling produce real
answers instead of a permanent all-green.

## Mock data

`data_gen/generate_swipes.py` writes 15,000 customer snapshots (500 customers ×
30 days) and 14,394 velocity rows (600 cards × 24 hours, minus 6 dropped to
create the stale case). Everything is anchored to run time, so regenerating
always produces data that materializes cleanly.

Six personas are pinned so demos are deterministic:

| Card / Customer | Setup | Demonstrates |
|---|---|---|
| `C-4471` / `CU-8842` | 7 swipes/hr, $2,140 vs $58.20 average, chargebacks null | The fraud case, and a null feature |
| `C-1002` / `CU-1002` | Everything median | Control |
| `C-7788` / `CU-3310` | Newest velocity row is 6h old | Staleness past a 2h TTL |
| `C-9999` | Never generated | Unknown entity |
| `CU-5150` | Profile but no card | Partial coverage |
| `C-3355` / `CU-4402` | 4 chargebacks, normal velocity | Risk that isn't velocity |

## The API

| Group | Endpoints |
|---|---|
| Catalog | `/entities` `/data-sources` `/feature-views` `/feature-views/{n}` `/feature-services` `/feature-services/{n}` `/features/search` `/cards/{id}` |
| Lineage | `/features/{view}/{feature}/lineage` `/feature-views/{n}/consumers` |
| Health | `/feature-views/{n}/freshness` `/health/materialization` `/feature-views/{n}/materialize` ⚠ |
| Values | `/features/online` `/features/explain` `/features/push` ⚠ |

Interactive docs at http://localhost:8000/docs.

The API is not a passthrough. It does three things the raw SDK does not:
joins registry metadata against online-store timestamps to compute freshness,
walks source → view → service to compute lineage, and flattens Feast's proto
shapes into plain named objects.

## MCP tools

12 read-only tools, plus 2 write tools that only register when writes are enabled.

`list_feature_views` · `describe_feature_view` · `list_feature_services` ·
`describe_feature_service` · `search_features` · `list_entities` · `resolve_card` ·
`get_feature_lineage` · `get_feature_consumers` · `check_feature_freshness` ·
`get_online_features` · `explain_features_for_entity` ·
`push_swipe` ⚠ · `trigger_materialization` ⚠

### Two kinds of freshness

These answer different questions, and confusing them is the most dangerous
mistake available here:

| Tool | Answers | Scope |
|---|---|---|
| `check_feature_freshness` | "Is a pipeline dead?" | All entities, view level |
| `explain_features_for_entity` | "Is *this card's* data current?" | One entity |

An individual entity can be six hours stale inside a view that materialized
seconds ago -- materialization writes whatever the source held, and for a card
with no recent rows that is an old value. So a view showing OK proves nothing
about any particular card.

A small model reliably conflates the two and answers "current enough to trust"
from view-level metadata. Three layers guard against it: the server
`INSTRUCTIONS`, the `check_feature_freshness` tool description, and a note
appended to that tool's **output** -- the last being the one that actually
worked, since a model that skipped the description still reads the result it
acted on.

### Why `explain_features_for_entity` exists

`get_online_features` returns bare values. A bare `null` cannot distinguish four
different situations, and Feast serves an expired value without complaint:

- a genuine zero
- a view that was never materialized
- an entity that does not exist
- a value that is past its TTL

`explain_features_for_entity` separates them, using the per-entity `event_ts`
recovered from the online store. That is why it is the preferred retrieval tool.

## `FEAST_MCP_READONLY`

Read by both processes. When true (the default), the MCP server does not
register `push_swipe` or `trigger_materialization` at all — a tool the model
cannot see is one it will not try — and the API independently returns 403 on
those routes, so curling it directly is also refused.

## Local LLM host

`host.py` is a real MCP host driven by a local open-source model -- no API key,
nothing hosted. The model decides which tools to call; `mcp_cli.py` only calls
tools you name.

```
ollama/qwen2.5:7b  ->  host.py  ->  MCP server  ->  Feature API  ->  Feast  ->  SQLite
```

```bash
ollama serve &                      # if not already running
ollama pull qwen2.5:7b              # any tool-calling model works

python3 host.py "Why would card C-4471 be flagged?"
python3 host.py --trace --quiet "Is anything stale?"
python3 host.py                     # interactive
```

The system prompt is **not written in host.py**. It comes from the MCP server's
own `instructions`, returned during `initialize()` -- the server tells the model
how its tools are meant to be used, and the host passes that through. Changing
`INSTRUCTIONS` in `mcp_server/server.py` changes how the model behaves, with no
edit to the host.

Model choice matters: it needs tool-calling support. `qwen2.5:7b` works;
Gemma has no tool template in Ollama and will not.

### Host guardrails

A 7B model is an unreliable planner, so the loop defends against three failures
it actually exhibits:

| Failure | Guardrail |
|---|---|
| Repeats a call it already made, sometimes until the step limit | Results cached by (tool, args); a repeat is served from cache with a "you already did this" note instead of a second round trip |
| Narrates its next call in prose (`"Next, let's call describe_feature_view"`) instead of emitting one | Detected, nudged once to emit the call rather than describe it (max 2) |
| Wanders past the step budget with no answer | On the last step -- or after 3 repeats -- tools are withdrawn, so it must answer from what it gathered |

Each prints a `HOST |` line, so you can see the loop intervening.

Even so, expect wandering on open-ended prompts. Restricting the toolset is the
practical fix:

```bash
python3 host.py --tools resolve_card,explain_features_for_entity,check_feature_freshness \
  "Why would card C-4471 be flagged?"
```

## Watching MCP call the API

`mcp_cli.py` speaks the same stdio protocol the host does, so the MCP -> API
chain is observable from a shell:

```bash
python3 mcp_cli.py tools                    # what is registered
python3 mcp_cli.py --trace demo             # 11-step walkthrough, with HTTP calls
python3 mcp_cli.py --trace call resolve_card '{"card_id": "C-4471"}'
```

`--trace` prints the endpoint each tool hits:

```
      http | HTTP Request: GET http://localhost:8000/cards/C-4471 "HTTP/1.1 200 OK"
C-4471 is owned by CU-8842
```

## Value lookups that land in one step

The reliable shape for a swipe lookup names three things: the **feature
service**, **both join keys**, and — via `--tools` — only the retrieval tool.
Each of these runs as a single tool call with no self-correction:

```bash
V="--tools explain_features_for_entity"

# the fraud case: 7 swipes, $2,140 against a $58.20 average, chargebacks null
python3 host.py $V "Explain the fraud_model_v2 features for card_id C-4471 and customer_id CU-8842. Why might this look suspicious?"

# control: everything median
python3 host.py $V "Explain the fraud_model_v2 features for card_id C-1002 and customer_id CU-1002. Does anything look unusual?"

# stale: card_velocity past its 2h TTL
python3 host.py $V "Explain the fraud_model_v2 features for card_id C-7788 and customer_id CU-3310. Is anything stale?"

# unknown card: three features absent, not zero
python3 host.py $V "Explain the fraud_model_v2 features for card_id C-9999 and customer_id CU-1002. Which are missing and why?"

# risk that is not velocity: 4 lifetime chargebacks
python3 host.py $V "Explain the fraud_model_v2 features for card_id C-3355 and customer_id CU-4402. Any risk signals?"

# partial coverage: customer has a profile but no card of their own
python3 host.py $V "Explain the fraud_model_v2 features for card_id C-1002 and customer_id CU-5150. Is coverage complete?"
```

Drop any of the three and a 7B model starts guessing — it reaches for a
`fraud_model` service that does not exist, or sends one join key instead of two.
It still recovers, because the API's errors name the valid options, but it takes
four or five steps instead of one.

The realistic variant costs those extra steps on purpose: a fraud alert names a
card, not a customer, so the model has to resolve the owner first.

```bash
python3 host.py --tools resolve_card,explain_features_for_entity \
  "Why would card C-4471 be flagged as suspicious?"
```

Run the whole set with `./demo_host.sh values`.

## What each prompt exercises

All eighteen prompts in `demo_host.sh` travel the same five hops. This section traces
one in full, then maps all eighteen to the tool, endpoint, Feast work and
registry object each one touches.

### Anatomy of one call

```bash
python3 host.py --tools explain_features_for_entity \
  "Explain the fraud_model_v2 features for card_id C-4471 and customer_id CU-8842. Why might this look suspicious?"
```

**1 · Host decides.** `host.py` sends the question to Ollama along with the tool
schemas and a system prompt taken from the MCP server's own `instructions`. The
model returns a tool call, not prose:

```json
{"name": "explain_features_for_entity",
 "arguments": {"entity_row": {"card_id": "C-4471", "customer_id": "CU-8842"},
               "feature_service": "fraud_model_v2"}}
```

**2 · MCP server translates.** `mcp_server/tools/values.py` turns that into one
HTTP request. It holds no Feast code — it is an `httpx` client:

```
POST http://localhost:8000/features/explain
```

**3 · API does the real work.** `api/routers/values.py` makes *two* passes over
the store, which is the reason this endpoint exists:

- `store.get_online_features(fraud_model_v2, [entity_row])` → the seven values
- `provider.online_read(...)` per view → the per-entity `event_timestamp`

then joins the timestamps against each view's TTL to classify every feature as
`FRESH`, `STALE`, `NULL_IN_SOURCE` or `ENTITY_NOT_FOUND`.

**4 · Feast reads storage.** The registry resolves `fraud_model_v2` to
`card_velocity` + `customer_profile`; SQLite returns values and timestamps.

**5 · Result.**

```
fraud_model_v2  ->  {'card_id': 'C-4471', 'customer_id': 'CU-8842'}
fresh 6   stale 0   missing 1

FRESH
card_velocity:txn_count_1h                7      54m41s
card_velocity:txn_count_24h               11     54m41s
card_velocity:amount_sum_1h               2,140  54m41s
customer_profile:avg_amount_30d           58.2   13h54m
customer_profile:distinct_merchants_30d   9      13h54m
customer_profile:home_country             "US"   13h54m

MISSING
customer_profile:chargebacks_lifetime     null   13h54m  entity present, feature null in source
```

The model then reasons over that: 7 swipes and $2,140 in an hour against a
$58.20 average — and chargeback history unavailable rather than zero.

---

### Catalog — reading the registry

Nothing here touches the online store. Every call reads registry metadata only.

| Prompt | MCP tool | Endpoint | What the API does | Manages | Result |
|---|---|---|---|---|---|
| "What feature views exist?" | `list_feature_views` | `GET /feature-views` | `store.list_feature_views()`, plus `last_materialized` derived from each view's `materialization_intervals` | both feature views | 2 rows: `customer_profile` batch/7d/4 features, `card_velocity` push/2h/3 features |
| "What models are registered and what features do they use?" | `list_feature_services` | `GET /feature-services` | `store.list_feature_services()`, resolving each projection to its views and entities | `fraud_model_v2` | 1 service, 7 features, 2 views, entities card + customer |
| "Describe the card_velocity view in detail." | `describe_feature_view` | `GET /feature-views/card_velocity` | `store.get_feature_view()` plus a scan of all feature services to find consumers | `card_velocity` | 3 features with dtypes, PushSource, 2h TTL, `consumed by: fraud_model_v2` |
| "Find me any feature related to chargebacks." | `search_features` | `GET /features/search?q=` | walks every view's features, matching name, description and tags | `chargebacks_lifetime` | 1 match: `customer_profile:chargebacks_lifetime`, Float64 |
| "What entities exist and what are their join keys?" | `list_entities` | `GET /entities` | `store.list_entities()` | `card`, `customer` | `card`→`card_id`, `customer`→`customer_id`, both STRING |

### Governance — lineage and blast radius

Also registry-only, but these two *derive* relationships Feast stores implicitly.

| Prompt | MCP tool | Endpoint | What the API does | Manages | Result |
|---|---|---|---|---|---|
| "Trace the lineage of chargebacks_lifetime in the customer_profile view." | `get_feature_lineage` | `GET /features/customer_profile/chargebacks_lifetime/lineage` | resolves the view's source, then scans feature services for consumers, and builds the chain | `chargebacks_lifetime` | `customer_profile.parquet → customer_profile_source → customer_profile → fraud_model_v2` |
| "If we delete the card_velocity view, what breaks?" | `get_feature_consumers` | `GET /feature-views/card_velocity/consumers` | scans every feature service's `feature_view_projections` for references | `card_velocity` | `NOT SAFE TO DELETE` — read by 1 service, `fraud_model_v2` |

**Name the view.** Asked loosely — *"where does chargebacks_lifetime come from?"*
— the model sends `feature_view: null`, takes a 404, and then works *around* the
failure with `describe_feature_view`, `search_features` and `list_feature_views`
rather than retrying it. Seven steps to an answer that is correct by elimination
but never retrieves the lineage chain at all. Naming the view makes it one call
that returns the whole chain. Same lesson as the value lookups below.

### Operations — is the data current

These two answer **different questions**, and the distinction is the sharpest
idea in the project.

| Prompt | MCP tool | Endpoint | What the API does | Manages | Result |
|---|---|---|---|---|---|
| "Is any feature pipeline stale or silently broken?" | `check_feature_freshness` | `GET /health/materialization` | compares each view's newest `materialization_intervals` end against its TTL — **across all entities** | both views | `overall: OK (0 of 2 need attention)`, plus a note that these ages are pipeline-level, not per entity |
| "Is the data for card C-7788, customer CU-3310, current enough to trust?" | `explain_features_for_entity` | `POST /features/explain` | per-entity `event_timestamp` via `provider.online_read`, joined against TTL | `card_velocity` for one card | `fresh 4 / stale 3` — velocity 6h50m old against a 2h TTL, with a warning |

A view can report `OK` while an individual card inside it is hours stale.
Materialization writes whatever the source holds, and for a card with no recent
rows that is an old value. The second prompt is the only one that can catch it.

### Values — the swipe lookup

All six use `POST /features/explain` and differ only in which entity they ask
about. Each lands in **one tool call**.

| Prompt covers | Entity | Result | What it proves |
|---|---|---|---|
| fraud | C-4471 / CU-8842 | `fresh 6 stale 0 missing 1` — 7 swipes, $2,140 vs $58.20 average | the signal a fraud model would fire on |
| control | C-1002 / CU-1002 | `fresh 7 stale 0 missing 0` — 1 swipe, $41.75 vs $45 average | what normal looks like |
| stale | C-7788 / CU-3310 | `fresh 4 stale 3 missing 0` — velocity 6h+ past a 2h TTL | expired values are still served, and flagged |
| unknown entity | C-9999 / CU-1002 | `fresh 4 stale 0 missing 3` — `ENTITY_NOT_FOUND` | absent is not zero |
| chargeback history | C-3355 / CU-4402 | `fresh 7` — `chargebacks_lifetime: 4`, velocity normal | risk that is not velocity |
| partial coverage | C-1002 / CU-5150 | `fresh 7` — profile resolves, card belongs to another customer | the two entities are independent |

The seventh, `VALUES · card only`, is the realistic shape — an alert names a
card, not a customer:

| Step | Tool | Endpoint |
|---|---|---|
| 1 | `resolve_card` | `GET /cards/C-4471` — reads `card_customer_map.parquet`, **not** Feast |
| 2 | `explain_features_for_entity` | `POST /features/explain` |

`resolve_card` is a demo affordance. In production both join keys arrive in the
swipe payload; locally this stands in for that.

### Writes — changing state

Only registered when `FEAST_MCP_READONLY=false`. The API independently returns
403 on these routes regardless.

| Prompt | MCP tool | Endpoint | What the API does | Manages | Result |
|---|---|---|---|---|---|
| "Record a swipe on card C-7788: 4 transactions this hour, 9 in 24 hours, 812.40 total." | `push_swipe` | `POST /features/push` | builds a one-row DataFrame and calls `store.push(push_source_name="card_velocity_source", to=PushMode.ONLINE)` | `card_velocity` for one card | row written on step 1; C-7788 flips from `stale 3` to `fresh 7`. The model then spends 4–5 more steps verifying it, usually guessing a `fraud_model` service before finding `fraud_model_v2` |
| "The card_velocity view is stale. Refresh it over the last 2 days." | `trigger_materialization` | `POST /feature-views/card_velocity/materialize` | `store.materialize(start, end, feature_views=["card_velocity"])` | `card_velocity`, all entities | completed in ~0.1s; C-7788 returns to `stale`, because the source's newest row for that card is still 6h old |

That second result is the useful one. Materializing does **not** make a stale
entity current — it writes whatever the source holds. Only a push can. It also
makes the demo repeatable: push to make C-7788 fresh, materialize to reset it.

### The pattern across all eighteen

| Group | Reads | Touches online store | Writes |
|---|---|---|---|
| Catalog | registry | no | no |
| Governance | registry | no | no |
| Operations | registry + online store | yes (`explain` only) | no |
| Values | registry + online store | yes | no |
| Writes | registry + online store | yes | yes |

Catalog and governance never leave the registry, which is why they are fast and
never stale. Anything answering "what is the value right now" has to cross into
the online store — and that is exactly where freshness stops being free.

---

## Try it

**Debug a decline**
> "Why would card C-4471 get declined?"

`list_feature_services` → `resolve_card` → `explain_features_for_entity`.
Returns 7 swipes in the last hour totalling $2,140 against a $58.20 average,
with chargeback history explicitly unavailable rather than assumed zero.

**Catch a dead pipeline**
> "Is anything stale for card C-7788?"

`explain_features_for_entity` flags `card_velocity` as 6h46m old against a 2h
TTL. The values still come back — nothing blocks the read — which is exactly
why the flag is needed.

**Push round trip** (needs writes enabled)
> "Record a swipe on C-7788, then check it again."

`push_swipe` → the same card reads fresh. `trigger_materialization` on
`card_velocity` resets it to the 6h-old batch row, so the demo is repeatable.

## Layout

```
requirements.txt  pinned, verified working set
preflight.py      interpreter + dependency check, run by both scripts
setup.sh          data + apply + materialize
run_api.sh        starts the API on the right interpreter
run_mcp.sh        starts the MCP server by hand (debugging)
start.sh          one command to a ready session -- idempotent
stop.sh           shut it down
demo_host.sh      run the verified host prompts, by group
mcp_cli.py        drives the MCP server from a shell, with --trace
host.py           local-LLM MCP host -- the model picks the tools

feature_repo/     Feast definitions + feature_store.yaml   (the only Feast config)
data_gen/         mock data generator
api/              FastAPI + the Feast SDK        <- the API boundary
  routers/        catalog | lineage | health | values
mcp_server/       MCP tools, HTTP client only    <- no Feast import
  tools/          catalog | lineage | health | values | admin
```

`api/routers/` and `mcp_server/tools/` mirror each other one-to-one.

## Notes

- **`chargebacks_lifetime` is `Float64`, not `Int64`.** The feature is genuinely
  nullable, and a null integer has no representation in the
  Parquet → pandas → Feast path.
- **Use `feast materialize`, not `materialize-incremental`, for setup.**
  Incremental uses the view's TTL as its start bound, so with a 2h TTL it would
  skip the 6h-old row that makes the stale persona work.
- **Registry caching.** `cache_ttl_seconds: 30` in `feature_store.yaml` means a
  `feast apply` in another shell shows up within 30s. `POST /admin/reload`
  forces it immediately, and also reopens the online store — which a bare registry refresh does not do.
- **The mock data is time-anchored.** `card_velocity` has a 2h TTL, so more than
  a couple of hours after `./setup.sh` every card reads stale and the personas
  stop being distinguishable. Re-run `./setup.sh`.
- **SQLite concurrency.** `feast materialize` writing while uvicorn reads can hit
  lock contention. Fine locally; it is not a production online store.