Skip to main content
Glama
GRaghavend

Car Buying Advisor MCP Server

by GRaghavend

Car Buying Advisor — MCP Server + Ollama Chat Loop

A decision-support tool for choosing between luxury car brands from real owner reviews: an MCP server exposing 6 deterministic, unit-testable tools over precomputed analysis tables, plus a chat loop that drives a local Ollama LLM (qwen2.5:7b-instruct) as a real MCP client of that server.

No tool calls an LLM internally — each is a plain Python function reading a pre-built CSV. The LLM synthesizes answers from tool results; comparison-by-aspect ("compare Audi and BMW on mileage") is achieved by the LLM composing multiple tool calls, not by a dedicated tool — see "Design notes" below.

Status: the MCP server, the Ollama chat loop, the FastAPI layer (backend/), and a real React + TypeScript frontend (frontend/) are all working end-to-end. See frontend/README.md for what the app does and how it maps to the tool contracts below, and steps_to_run.md for the exact commands to bring up the backend and frontend together locally.

Repository layout

The repo is organized to mirror the order the project was actually built in — raw data in, tables out, tools on top of the tables, chat loop on top of the tools:

eda/                      1. Exploratory analysis on the raw reviews
sentiment_classification/ 2. Picks and applies a sentiment model
topic_modelling/          3. BERTopic topic modeling + aspect table
data/
  raw/                       Original review CSVs (input to step 1)
  interim/                   Intermediate pipeline artifacts
  processed/                 Final tables the MCP server reads
  README.md                  What lives in each of the 3 layers above, and who produces/consumes it
mcp_server/                4. The MCP server, its tools, and the Ollama client
backend/                   5. FastAPI layer exposing the chat loop over HTTP
frontend/                  6. The chat + evidence-panel UI (React + TypeScript + Vite)
scripts/                   Throwaway manual-run scripts (not shipped code)
logs/                       Structured JSON logs (git-ignored, created at runtime)

Each stage's own README (where one exists) has the detail; this file is the map between them plus how to run everything.

Related MCP server: Brand Knowledge MCP Server

1. EDA — eda/

Three notebooks establish dataset shape and quality, and produce the first tables the MCP server reads directly (brand_stats.csv, rating_trends.csv, cleaned_reviews.csv). Run in the order listed in eda/README.md — that file also has the full data-quality findings and how they informed later steps.

2. Sentiment classification — sentiment_classification/

sentimentclassification.ipynb evaluates candidate sentiment models (compared against the gold-labeled 3,700-review set validated in step 1) and applies the winning model to the full 38K reviews. This notebook runs on Kaggle, not locally: /kaggle/working/... output paths are that platform's own scratch directory and are left as-is, but the /kaggle/input/.../car-review-chatbot/... input paths now mirror this repo's data/raw and data/interim — i.e. the Kaggle Dataset itself needs a raw/ and interim/ subfolder matching this repo before the notebook will run (its local-relative outputs, e.g. phase4_model_comparison.csv, are written under data/interim/). Its sentiment predictions feed directly into topic modelling below. See sentiment_classification/README.md for the validation-set-vs-actual-dataset distinction, the 4 candidate models compared, and exactly where the comparison results and final predictions are stored.

3. Topic modelling — topic_modelling/

topicmodelling.ipynb (also run on Kaggle, same raw//interim/ Kaggle-Dataset-layout requirement as above) runs BERTopic over the review text and joins the result with the sentiment predictions from step 2, producing aspect_sentiment_table.csv — one row per review with its topic, keywords, and sentiment. Downloaded from Kaggle's working directory into data/processed/. See topic_modelling/README.md for the full pipeline, including the human topic-labeling step (39 labeled topics + 1 outlier bucket).

build_topic_info.py is a small local, deterministic script that derives data/processed/phase5_topic_info.csv (one row per topic: id, label, keywords, review count) from aspect_sentiment_table.csv. It's the only step in the pipeline that runs locally rather than on Kaggle:

python3 -m venv .venv_mcp
source .venv_mcp/bin/activate
pip install -r requirements.txt

python topic_modelling/build_topic_info.py

After this step, all 5 tables the MCP server needs exist in data/processed/:

File

Produced by

brand_stats.csv

eda/EDA.ipynb

rating_trends.csv

eda/EDA.ipynb

cleaned_reviews.csv

eda/EDA_NPL_38K.ipynb

aspect_sentiment_table.csv

topic_modelling/topicmodelling.ipynb (Kaggle)

phase5_topic_info.csv

topic_modelling/build_topic_info.py

4. MCP server + chat loop — mcp_server/

mcp_server/server.py and mcp_server/client.py run as two separate OS processes, talking over the actual Model Context Protocol (stdio transport, JSON-RPC) — not a plain in-process function call:

Ollama (qwen2.5:7b-instruct)
        │  tool schemas ← fetched live from the MCP server's list_tools()
        │  tool_calls (name + args)
        ▼
mcp_server/client.py  (MCP client, process A)
        │  MCP call_tool() over stdio (JSON-RPC)
        ▼
mcp_server/server.py  (MCP server, process B — spawned as a subprocess)
        │  validates arguments itself (from the same schema it published)
        ▼
pandas DataFrames (5 CSVs from data/processed/, loaded once at server startup)

The tool JSON schemas handed to Ollama are not hand-maintained — they come straight from the live server's list_tools() response, generated from each tool function's own type-hinted signature (see mcp_server/tools/, one file per tool). The server is the single source of truth for what it can do; nothing in client.py duplicates that. See mcp_server/README.md for the file-by-file breakdown of that folder.

Logging across the process boundary: each process logs to its own file (logs/app.log for the client/orchestrator, logs/mcp_server.log for the tool server) — two independent RotatingFileHandlers rotating the same file from separate processes can race and corrupt it. A request_id set in client.py does not automatically appear in the server's log lines — contextvars don't cross an OS process boundary. Today, matching a chat turn's server-side tool logs to its client-side request_id means lining up timestamps between the two files (scripts/manual_test.py prints a reminder of this). The correct fix is to thread request_id through MCP's per-call meta field and have server-side tools accept an injected Context parameter to log through the protocol itself — a real upgrade, deliberately deferred rather than done as a rushed addition to already-tested tool code.

Setup and running

python3 -m venv .venv_mcp
source .venv_mcp/bin/activate
pip install -r requirements.txt

# One-time (step 3 above, repeated here for convenience):
python topic_modelling/build_topic_info.py

# Requires Ollama running locally with the model pulled:
#   ollama pull qwen2.5:7b-instruct
python scripts/manual_test.py

scripts/manual_test.py spawns mcp_server/server.py as a subprocess itself — you do not run server.py separately. All data is loaded into memory once, at the server subprocess's startup. If a required file is missing or a required column isn't present, that subprocess exits immediately with a fatal error instead of starting silently broken (visible in logs/mcp_server.log and in the client's stderr).

Edit the QUERY constant at the top of scripts/manual_test.py to try different questions (single-tool, compound/multi-tool, unknown-brand). It prints the model's answer, every tool call made (name, args, status, duration), and the client-side structured log lines for that exact run.

5. Backend API — backend/

A thin FastAPI layer in front of mcp_server/client.py, so the frontend (a separate app, different origin during local dev) can drive the chat loop over HTTP instead of importing Python directly. It owns the one long-lived MCPToolClient (started once at API startup, not per request) and an in-memory conversation_id -> history store, since run_chat() itself holds neither. See backend/README.md for the route list and its known v1 shortcuts (in-memory history with no eviction, permissive local-dev CORS).

source .venv_mcp/bin/activate
pip install -r requirements.txt
uvicorn backend.main:app --reload

Tool → data file map

Tool

Reads

Purpose

get_brand_stats(brand_name)

data/processed/brand_stats.csv

Avg/median rating, review count, rating std, avg model year for one brand

compare_brands(brand_names)

data/processed/brand_stats.csv

Same stats for N brands, side by side (calls get_brand_stats per brand)

get_common_complaints(brand_name, topic_filter=None, top_n=5)

data/processed/aspect_sentiment_table.csv, data/processed/phase5_topic_info.csv

Top negative-sentiment topics for a brand, optionally narrowed to one aspect

get_common_praises(brand_name, topic_filter=None, top_n=5)

data/processed/aspect_sentiment_table.csv, data/processed/phase5_topic_info.csv

Same, for positive-sentiment topics

get_rating_trends(brand_name=None, year_range=None)

data/processed/rating_trends.csv

Avg rating per model year, optionally filtered by brand and/or year range

search_reviews(query, brand_name=None, min_rating=None, limit=20)

data/processed/cleaned_reviews.csv

Case-insensitive substring search over raw review text

data/processed/phase5_topic_info.csv is a reference table (topic_id, topic_label, top_keywords, review_count) derived from aspect_sentiment_table.csv by topic_modelling/build_topic_info.py. It isn't a primary source for any tool — it's used to validate/fuzzy-match a caller's topic_filter string against the 40 real BERTopic labels, and to tell the caller which topics do exist when their filter doesn't match anything.

Data coverage

brand_stats.csv, rating_trends.csv, cleaned_reviews.csv, and aspect_sentiment_table.csv all cover the same 5 brands: Audi, BMW, INFINITI, Lexus, Mercedes-Benz (31,938 reviews total). Brand name matching is case-insensitive; an unrecognized brand always returns an explicit brand_not_found status plus the list of brands that do exist — never an empty list or a crash.

topic_filter matching

get_common_complaints / get_common_praises resolve topic_filter in two passes:

  1. Substring match against topic_label and top_keywords (case-insensitive) — e.g. "mileage" matches the topic labeled "high mileage & maintenance reliability".

  2. Fuzzy match (difflib.get_close_matches, cutoff 0.4) against known topic_label values, for near-miss spellings, if the substring pass finds nothing.

If neither pass matches, the tool returns status="topic_not_found" plus available_topics (all 40 known labels) instead of silently returning [].

Error handling contract

Every tool returns a Pydantic model with a status field — never a bare None, an empty list with no explanation, or an unhandled exception:

  • Unknown brand → status="brand_not_found", plus available_brands.

  • Unknown/unmatched topic → status="topic_not_found", plus available_topics.

  • Valid brand/topic but zero matching rows → status="no_results", plus a human-readable message explaining why.

  • Otherwise → status="ok" with the populated result.

Design notes: why there's no "compare by aspect" tool

A query like "Compare Audi and BMW on mileage" is not a single tool call. There is no compare_brands_by_topic tool because it would just re-implement calling get_common_praises / get_common_complaints twice each (once per brand) and returning the same data those tools already return. Instead, the LLM composes:

get_common_praises("Audi", topic_filter="mileage")
get_common_complaints("Audi", topic_filter="mileage")
get_common_praises("BMW", topic_filter="mileage")
get_common_complaints("BMW", topic_filter="mileage")

and synthesizes the comparison itself from the four results. Adding a dedicated 7th tool would duplicate logic that composition of the existing 6 tools already covers — the tools are kept single-purpose and composable by design.

Testing without an LLM

Every tool is still a plain importable function (no subprocess needed), so it's unit-testable directly by importing the tool modules in-process:

import sys
sys.path.insert(0, "mcp_server")

from tools import get_brand_stats, compare_brands, get_common_complaints, get_common_praises, get_rating_trends, search_reviews

get_brand_stats("bmw")                                   # case-insensitive
get_brand_stats("Toyota")                                # -> brand_not_found
compare_brands(["Audi", "BMW"])
get_common_complaints("Audi", topic_filter="mileage")
get_common_praises("Audi", topic_filter="interior")
get_rating_trends("Audi", year_range=(2015, 2018))
search_reviews("transmission", brand_name="Audi", limit=3)

Testing the full MCP + LLM loop

source .venv_mcp/bin/activate
python scripts/manual_test.py

Running the whole thing (backend + frontend)

See steps_to_run.md for the exact commands, in order, to bring up Ollama, the FastAPI backend, and the frontend dev server together.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Provides 25 interactive automotive intelligence tools for real-time market data, including VIN decoding, price predictions, and inventory analytics. It enables AI assistants to perform car searches, trade-in estimations, and market trend analysis using the Model Context Protocol.
    2
    -
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables discovery and analysis of automotive brand regional sales data, including brand details, regional store layouts, model sales rankings, and dealer information.
    1
    -