mcp_feast
An MCP server over a Feast feature store, for a card-swipefraud model. Runs entirely locally: Parquet offline store, SQLite online store,no cloud, no broker.
System design
The four layers
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 belowit. The MCP server above it needs no Feast install, no store drivers, and nowarehouse credentials — it is an HTTP client and nothing more.
That buys three things. Swapping SQLite for Redis becomes a feature_store.yamlchange the MCP layer never sees. A laptop running the MCP server needs onereachable URL instead of a network route to production Redis. And the same APIcan serve a second consumer — a model server — that was never built here butwould 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:
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:
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
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 onpurpose: it runs a minutes-long query returning millions of rows, which is thewrong shape for a chat tool. That is also why the generator writes no fraudlabels.
Why the API is not a passthrough
get_online_features() returns values and nothing else. A bare null cannottell you which of four situations you are in — and Feast serves an expiredvalue without complaint:
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-entityevent_timestamp through the provider's online_read — the same callget_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 withdifferent answers, and confusing them is the most dangerous mistake availablehere:
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 recentrows that is an old value — so an entity can be hours stale inside a view thatmaterialized 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 systemprompt — 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
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 abad entity row names the join keys it needs. Observed repeatedly: a 7B modelgets it wrong, reads the error, and fixes itself on the next step rather thanguessing again.
Guidance rides on output, not just descriptions. Tool descriptions getskipped; results do not. Both the freshness scope warning andtrigger_materialization's "call check_feature_freshness to confirm" live inthe returned text, and both changed model behaviour when prompt wording alonehad failed.
Quick start
Python 3.11. Feast declares >=3.10 but classifies only 3.10, and itstransitive stack is the usual source of trouble on newer interpreters.
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
./setup.sh # preflight + data + apply + materialize
./run_api.sh # API on :8000, docs at /docs
Both scripts honour a PYTHON override if the deps live elsewhere:
PYTHON=/opt/miniconda3/envs/myenv/bin/python3 ./setup.sh
The MCP server is launched by the host via .mcp.json, which pins an absoluteinterpreter path for the reason in Troubleshooting below. ./run_mcp.sh runs itby hand for debugging.
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 exposesmcp.server.fastmcp.FastMCP, not the 2.x mcp.server.MCPServer this projectuses. A shell prompt showing an active conda env is not proof -- check PATH:
which python3 && python3 -V
echo $PATH | tr ":" "\n" | head -3
If a framework or system Python sits ahead of your env, every python3 callescapes the env regardless of what the prompt says. Diagnose properly with:
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 orfeast on your PATH belong to a different environment.
Every entry point takes a PYTHON override, so you never have to fight PATH:
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, orpython3 -m uvicorn api.main:app.Never bareuvicorn api.main:app— that resolves uvicorn from PATH, which maybelong to a different Python than the one holding Feast, and the failuresurfaces forty frames deep in an import chain. - Keep
.mcp.json'scommandan absolute interpreter path."python3"thereresolves 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 realanswers 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 tocreate the stale case). Everything is anchored to run time, so regeneratingalways 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 protoshapes 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 dangerousmistake 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 materializedseconds ago -- materialization writes whatever the source held, and for a cardwith no recent rows that is an old value. So a view showing OK proves nothingabout 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 serverINSTRUCTIONS, the check_feature_freshness tool description, and a noteappended to that tool's output -- the last being the one that actuallyworked, since a model that skipped the description still reads the result itacted on.
Why explain_features_for_entity exists
get_online_features returns bare values. A bare null cannot distinguish fourdifferent 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_tsrecovered 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 notregister push_swipe or trigger_materialization at all — a tool the modelcannot see is one it will not try — and the API independently returns 403 onthose 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 callstools you name.
ollama/qwen2.5:7b -> host.py -> MCP server -> Feature API -> Feast -> SQLite
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'sown instructions, returned during initialize() -- the server tells the modelhow its tools are meant to be used, and the host passes that through. ChangingINSTRUCTIONS in mcp_server/server.py changes how the model behaves, with noedit 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 failuresit 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 thepractical fix:
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 -> APIchain is observable from a shell:
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
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 2hTTL. The values still come back — nothing blocks the read — which is exactlywhy 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 oncard_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)
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_lifetimeisFloat64, notInt64. The feature is genuinelynullable, and a null integer has no representation in theParquet → pandas → Feast path.- Use
feast materialize, notmaterialize-incremental, for setup.Incremental uses the view's TTL as its start bound, so with a 2h TTL it wouldskip the 6h-old row that makes the stale persona work. - Registry caching.
cache_ttl_seconds: 30infeature_store.yamlmeans afeast applyin another shell shows up within 30s.POST /admin/reloadforces it immediately, and also reopens the online store — which a bare registry refresh does not do. - The mock data is time-anchored.
card_velocityhas a 2h TTL, so more thana couple of hours after./setup.shevery card reads stale and the personasstop being distinguishable. Re-run./setup.sh. - SQLite concurrency.
feast materializewriting while uvicorn reads can hitlock contention. Fine locally; it is not a production online store.