Skip to content

Commit c7dc420

Browse files
committed
Add MCP server, --min-price floor, tests, and CI (v0.1.0)
- --min-price drops placeholder prices (e.g. 1.000) that polluted --cheapest - MCP server (pitstop-mcp, optional [mcp] extra) exposes list_fuels, find_stations, find_cheapest over the same core as the CLI - extract shared core.query_stations / core.response_envelope - pytest suite (parsing, join, haversine, price filtering) and GitHub Actions CI
1 parent 4ab5ef3 commit c7dc420

10 files changed

Lines changed: 465 additions & 60 deletions

File tree

.github/workflows/ci.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
python-version: ["3.10", "3.12"]
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: actions/setup-python@v5
17+
with:
18+
python-version: ${{ matrix.python-version }}
19+
- run: python -m pip install --upgrade pip
20+
- run: pip install -e ".[dev]"
21+
- run: pytest -q
22+
23+
mcp-import:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v4
27+
- uses: actions/setup-python@v5
28+
with:
29+
python-version: "3.12"
30+
- run: pip install -e ".[mcp]"
31+
- run: python -c "import pitstop.mcp_server; print('mcp server imports OK')"
32+
33+
build:
34+
runs-on: ubuntu-latest
35+
steps:
36+
- uses: actions/checkout@v4
37+
- uses: actions/setup-python@v5
38+
with:
39+
python-version: "3.12"
40+
- run: pip install build
41+
- run: python -m build

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims
55
to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.1.0] - 2026-05-29
8+
9+
### Added
10+
- `--min-price` floor on `pitstop stations` to drop placeholder/junk prices
11+
(e.g. `1.000`) that otherwise pollute `--cheapest`.
12+
- **MCP server** (`pitstop-mcp`, optional `[mcp]` extra) exposing `list_fuels`,
13+
`find_stations`, and `find_cheapest` over the same core as agent tools.
14+
- pytest test suite covering parsing, the registry+price join, geo distance,
15+
and price filtering.
16+
- GitHub Actions CI (tests on Python 3.10/3.12, MCP import check, wheel build).
17+
18+
### Changed
19+
- Filtering and sorting moved into a shared `core.query_stations` /
20+
`core.response_envelope` used by both the CLI and the MCP server.
21+
722
## [0.0.1] - 2026-05-29
823

924
Initial release.
@@ -26,4 +41,5 @@ Initial release.
2641
- `--fuel` is a substring match, so `Gasolio` also matches variants such as
2742
`Gasolio Alpino`.
2843

44+
[0.1.0]: https://github.com/galjos/pitstop/releases/tag/v0.1.0
2945
[0.0.1]: https://github.com/galjos/pitstop/releases/tag/v0.0.1

README.md

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ PYTHONPATH=src python3 -m pitstop --help
4040
## Usage
4141

4242
```bash
43-
# Cheapest diesel in a municipality
44-
pitstop stations --comune ROMA --fuel Gasolio --cheapest --limit 5
43+
# Cheapest diesel in a municipality (skip placeholder prices with --min-price)
44+
pitstop stations --comune ROMA --fuel Gasolio --cheapest --min-price 1.2 --limit 5
4545

4646
# Self-service petrol within 5 km of a coordinate, as JSON
4747
pitstop stations --near 46.498,11.354 --radius 5 --fuel Benzina --self --json
@@ -50,7 +50,29 @@ pitstop stations --near 46.498,11.354 --radius 5 --fuel Benzina --self --json
5050
pitstop fuels
5151
```
5252

53-
`stations` flags: `--comune`, `--provincia`, `--brand`, `--near "lat,lon"`, `--radius`, `--fuel` (substring, case-insensitive), `--self`, `--served`, `--cheapest` (needs `--fuel`), `--limit`, `--json`. Loading flags (`--refresh`, `--max-age`, `--timeout`) apply to any data command.
53+
`stations` flags: `--comune`, `--provincia`, `--brand`, `--near "lat,lon"`, `--radius`, `--fuel` (substring, case-insensitive), `--self`, `--served`, `--cheapest` (needs `--fuel`), `--min-price` (drop values below a floor; e.g. `1.2` to skip placeholders), `--limit`, `--json`. Loading flags (`--refresh`, `--max-age`, `--timeout`) apply to any data command.
54+
55+
## MCP server
56+
57+
For agents that speak MCP, the same data is exposed as tools (`list_fuels`, `find_stations`, `find_cheapest`) over the shared core:
58+
59+
```bash
60+
pip install "pitstop[mcp]" # or: uv tool install "pitstop[mcp]"
61+
pitstop-mcp # stdio MCP server
62+
```
63+
64+
Example client config entry:
65+
66+
```json
67+
{ "mcpServers": { "pitstop": { "command": "pitstop-mcp" } } }
68+
```
69+
70+
## Development
71+
72+
```bash
73+
pip install -e ".[dev]"
74+
pytest -q
75+
```
5476

5577
## Automation contract
5678

@@ -62,12 +84,9 @@ pitstop fuels
6284

6385
## Status & roadmap
6486

65-
v0.0.1working fuel-price core (registry+price join, filters, proximity, cheapest, JSON).
87+
v0.1.0 — fuel-price core (registry+price join, filters, proximity, cheapest, `--min-price` floor, JSON), an MCP server, a Claude skill, tests, and CI.
6688

6789
Planned, roughly in order:
68-
- price sanity-floor to suppress placeholder values;
69-
- an **MCP server** exposing the same core as agent tools;
70-
- a Claude **skill** that drives the CLI;
7190
- **EV charging** (locations via Open Charge Map; prices via the AFIR National Access Point / DATEX II as that data matures);
7291
- additional countries behind a per-country source adapter (e.g. Germany Tankerkönig, France/Spain official feeds).
7392

pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,25 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pitstop"
7-
version = "0.0.1"
7+
version = "0.1.0"
88
description = "Unofficial JSON-first CLI for Italian fuel-station prices (MIMIT Osservaprezzi Carburanti open data)."
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
dependencies = []
1212
authors = [{ name = "Josef M. Gallmetzer" }]
1313
keywords = ["fuel", "prices", "italy", "mimit", "carburanti", "cli", "agent"]
1414

15+
[project.optional-dependencies]
16+
mcp = ["mcp>=1.0"]
17+
dev = ["pytest>=7"]
18+
1519
[project.scripts]
1620
pitstop = "pitstop.cli:main"
21+
pitstop-mcp = "pitstop.mcp_server:main"
1722

1823
[tool.hatch.build.targets.wheel]
1924
packages = ["src/pitstop"]
25+
26+
[tool.pytest.ini_options]
27+
pythonpath = ["src"]
28+
testpaths = ["tests"]

skills/pitstop/SKILL.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ Always pass `--json` when consuming the output programmatically.
2121

2222
```bash
2323
# Cheapest of a fuel in a municipality (--cheapest requires --fuel)
24-
pitstop stations --comune ROMA --fuel Gasolio --cheapest --limit 5 --json
24+
# Add --min-price 1.2 to skip placeholder values when ranking by price.
25+
pitstop stations --comune ROMA --fuel Gasolio --cheapest --min-price 1.2 --limit 5 --json
2526

2627
# Nearest stations to a coordinate, self-service petrol within 5 km
2728
pitstop stations --near 46.498,11.354 --radius 5 --fuel Benzina --self --json
@@ -30,7 +31,9 @@ pitstop stations --near 46.498,11.354 --radius 5 --fuel Benzina --self --json
3031
pitstop fuels --json
3132
```
3233

33-
Key flags: `--comune`, `--provincia` (2-letter, e.g. `BZ`), `--brand`, `--near "lat,lon"` + `--radius` (km), `--fuel` (substring, case-insensitive), `--self` / `--served`, `--cheapest`, `--limit`, `--json`.
34+
Key flags: `--comune`, `--provincia` (2-letter, e.g. `BZ`), `--brand`, `--near "lat,lon"` + `--radius` (km), `--fuel` (substring, case-insensitive), `--self` / `--served`, `--cheapest`, `--min-price` (price floor), `--limit`, `--json`.
35+
36+
If your client speaks MCP instead of shelling out, the same capabilities are available as MCP tools (`list_fuels`, `find_stations`, `find_cheapest`) via `pitstop-mcp` (install `pitstop[mcp]`).
3437

3538
## JSON contract
3639

@@ -42,4 +45,4 @@ Exit codes: `0` ok, `1` runtime error (e.g. network), `2` usage error.
4245

4346
- **Daily, not real-time** — prices are as of ~08:00 the day before `price_extraction_date`. State this when answering.
4447
- **`--fuel` is a substring match**`Gasolio` also matches `Gasolio Premium`, `Gasolio Oro Diesel`, etc. Use `pitstop fuels` to pick exact names; inspect the `fuel` field in results.
45-
- **Placeholder prices** — some operators report junk values like `1.000`; with `--cheapest` these can rank first. Sanity-check the lowest results before reporting them as real.
48+
- **Placeholder prices** — some operators report junk values like `1.000`; with `--cheapest` these can rank first. Pass `--min-price 1.2` (petrol/diesel) to drop them; use a lower floor for cheaper fuels like GPL. Still sanity-check the lowest result.

src/pitstop/cli.py

Lines changed: 19 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def _build_parser() -> argparse.ArgumentParser:
5959
stations.add_argument("--self", dest="self_only", action="store_true", help="only self-service prices")
6060
stations.add_argument("--served", dest="served_only", action="store_true", help="only served prices")
6161
stations.add_argument("--cheapest", action="store_true", help="sort by ascending price (needs --fuel)")
62+
stations.add_argument("--min-price", dest="min_price", type=float, default=0.0,
63+
help="drop prices below this floor (e.g. 1.2 to skip placeholder values); 0 = off")
6264
stations.add_argument("--limit", type=int, default=20, help="max stations; 0 = no limit")
6365
stations.set_defaults(func=_cmd_stations)
6466

@@ -104,31 +106,20 @@ def _cmd_stations(args) -> int:
104106

105107
ds = _load(args)
106108

107-
out: list[core.Station] = []
108-
for st in ds.stations.values():
109-
if args.comune and st.comune.casefold() != args.comune.strip().casefold():
110-
continue
111-
if args.provincia and st.provincia.casefold() != args.provincia.strip().casefold():
112-
continue
113-
if args.brand and args.brand.lower() not in st.brand.lower():
114-
continue
115-
116-
prices = core.filter_prices(st.prices, args.fuel, args.self_only, args.served_only)
117-
if (args.fuel or args.self_only or args.served_only) and not prices:
118-
continue
119-
st.prices = prices
120-
121-
if use_near:
122-
d = core.haversine_km(near_lat, near_lon, st.lat, st.lon)
123-
if d > args.radius:
124-
continue
125-
st.distance_km = round(d, 2)
126-
out.append(st)
127-
128-
_sort_stations(out, cheapest=args.cheapest, use_near=use_near)
129-
130-
if args.limit > 0:
131-
out = out[: args.limit]
109+
out = core.query_stations(
110+
ds,
111+
comune=args.comune,
112+
provincia=args.provincia,
113+
brand=args.brand,
114+
near=(near_lat, near_lon) if use_near else None,
115+
radius_km=args.radius,
116+
fuel=args.fuel,
117+
self_only=args.self_only,
118+
served_only=args.served_only,
119+
cheapest=args.cheapest,
120+
min_price=args.min_price,
121+
limit=args.limit,
122+
)
132123

133124
query: dict = {}
134125
for key, val in (("comune", args.comune), ("provincia", args.provincia),
@@ -144,6 +135,8 @@ def _cmd_stations(args) -> int:
144135
query["served"] = True
145136
if args.cheapest:
146137
query["cheapest"] = True
138+
if args.min_price > 0:
139+
query["min_price"] = args.min_price
147140

148141
if args.as_json:
149142
return _print_stations_json(ds, out, query)
@@ -174,28 +167,8 @@ def _cmd_fuels(args) -> int:
174167
return 0
175168

176169

177-
def _sort_stations(out: list[core.Station], *, cheapest: bool, use_near: bool) -> None:
178-
if cheapest:
179-
out.sort(key=lambda s: (core.min_price(s.prices) is None,
180-
core.min_price(s.prices) or 0.0))
181-
elif use_near:
182-
out.sort(key=lambda s: s.distance_km if s.distance_km is not None else float("inf"))
183-
else:
184-
out.sort(key=lambda s: (s.comune, s.name))
185-
186-
187170
def _print_stations_json(ds: core.Dataset, stations: list[core.Station], query: dict) -> int:
188-
_dump({
189-
"source": core.SOURCE_NAME,
190-
"source_url": core.SOURCE_URL,
191-
"registry_extraction_date": ds.registry_date,
192-
"price_extraction_date": ds.price_date,
193-
"generated_at": core.now_iso(),
194-
"query": query,
195-
"count": len(stations),
196-
"stations": [s.to_dict() for s in stations],
197-
"disclaimer": core.DISCLAIMER,
198-
})
171+
_dump(core.response_envelope(ds, stations, query))
199172
return 0
200173

201174

src/pitstop/core.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,11 @@ def load(
204204

205205

206206
def filter_prices(
207-
prices: list[Price], fuel: str = "", self_only: bool = False, served_only: bool = False
207+
prices: list[Price],
208+
fuel: str = "",
209+
self_only: bool = False,
210+
served_only: bool = False,
211+
min_price: float = 0.0,
208212
) -> list[Price]:
209213
fuel = fuel.strip().lower()
210214
out = []
@@ -215,14 +219,81 @@ def filter_prices(
215219
continue
216220
if served_only and p.self_service:
217221
continue
222+
if min_price > 0 and p.price < min_price:
223+
continue
218224
out.append(p)
219225
return out
220226

221227

222-
def min_price(prices: list[Price]) -> float | None:
228+
def min_price_of(prices: list[Price]) -> float | None:
223229
return min((p.price for p in prices), default=None)
224230

225231

232+
def query_stations(
233+
ds: Dataset,
234+
*,
235+
comune: str = "",
236+
provincia: str = "",
237+
brand: str = "",
238+
near: tuple[float, float] | None = None,
239+
radius_km: float = 10.0,
240+
fuel: str = "",
241+
self_only: bool = False,
242+
served_only: bool = False,
243+
cheapest: bool = False,
244+
min_price: float = 0.0,
245+
limit: int = 20,
246+
) -> list[Station]:
247+
"""Filter, sort, and limit stations. Mutates the dataset's Station objects
248+
(narrows prices, sets distance_km), so pass a freshly loaded Dataset."""
249+
out: list[Station] = []
250+
for st in ds.stations.values():
251+
if comune and st.comune.casefold() != comune.strip().casefold():
252+
continue
253+
if provincia and st.provincia.casefold() != provincia.strip().casefold():
254+
continue
255+
if brand and brand.lower() not in st.brand.lower():
256+
continue
257+
258+
prices = filter_prices(st.prices, fuel, self_only, served_only, min_price)
259+
if (fuel or self_only or served_only or min_price > 0) and not prices:
260+
continue
261+
st.prices = prices
262+
263+
if near is not None:
264+
d = haversine_km(near[0], near[1], st.lat, st.lon)
265+
if d > radius_km:
266+
continue
267+
st.distance_km = round(d, 2)
268+
out.append(st)
269+
270+
if cheapest:
271+
out.sort(key=lambda s: (min_price_of(s.prices) is None, min_price_of(s.prices) or 0.0))
272+
elif near is not None:
273+
out.sort(key=lambda s: s.distance_km if s.distance_km is not None else float("inf"))
274+
else:
275+
out.sort(key=lambda s: (s.comune, s.name))
276+
277+
if limit > 0:
278+
out = out[:limit]
279+
return out
280+
281+
282+
def response_envelope(ds: Dataset, stations: list[Station], query: dict) -> dict:
283+
"""Build the stable JSON response object shared by the CLI and MCP server."""
284+
return {
285+
"source": SOURCE_NAME,
286+
"source_url": SOURCE_URL,
287+
"registry_extraction_date": ds.registry_date,
288+
"price_extraction_date": ds.price_date,
289+
"generated_at": now_iso(),
290+
"query": query,
291+
"count": len(stations),
292+
"stations": [s.to_dict() for s in stations],
293+
"disclaimer": DISCLAIMER,
294+
}
295+
296+
226297
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
227298
r = 6371.0
228299
d_lat = math.radians(lat2 - lat1)

0 commit comments

Comments
 (0)