sidbu546

mcp_feast

Community sidbu546
Updated

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, or python3 -m uvicorn api.main:app.Never bare uvicorn 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's command an absolute interpreter path. "python3" thereresolves against whatever PATH the host process happened to have.

What is in the registry

Entitiescard (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 servicefraud_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_servicesresolve_cardexplain_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_lifetime is Float64, not Int64. The feature is genuinelynullable, and a null integer has no representation in theParquet → 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 wouldskip the 6h-old row that makes the stale persona work.
  • Registry caching. cache_ttl_seconds: 30 in feature_store.yaml means afeast apply in 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_velocity has a 2h TTL, so more thana couple of hours after ./setup.sh every card reads stale and the personasstop being distinguishable. Re-run ./setup.sh.
  • SQLite concurrency. feast materialize writing while uvicorn reads can hitlock contention. Fine locally; it is not a production online store.

MCP Server · Populars

MCP Server · New

    Get-Concord-AI

    Concord MCP

    Live messaging for coding agents

    Community Get-Concord-AI
    alijancb

    Subio MCP

    Open-source MCP server for discovering fast-growing internet conversations with Subio

    Community alijancb
    ruezo

    MCP Video Digest (视频内容提取总结)

    MCP Server for transcribing videos via video links and summarizing video content

    Community ruezo
    LastSearch-HQ

    LastSearch

    Reliable research infrastructure for AI agents. Evidence-backed web search with citations, confidence scores, and Clarity anti-hallucination. MCP server, REST API, Python SDK.

    Community LastSearch-HQ
    gtfodevs

    Autonomo MCP

    Tired of 'it works' lies? Autonomo MCP makes your AI prove it—on real hardware, right in your editor.

    Community gtfodevs