stac-eo-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """MCP server for searching STAC catalogs and analysing Earth observation data."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from stac_eo_mcp.server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,241 @@
1
+ """Tool workflows: scene -> window read -> reflectance -> index -> stats/files."""
2
+
3
+ import logging
4
+ from collections import defaultdict
5
+ from concurrent.futures import ThreadPoolExecutor
6
+
7
+ import numpy as np
8
+ import pystac
9
+ import xarray as xr
10
+ from shapely.geometry import box, shape
11
+
12
+ from stac_eo_mcp import catalog, raster
13
+ from stac_eo_mcp.config import CatalogName, Settings
14
+ from stac_eo_mcp.errors import InvalidInputError, StacMcpError
15
+ from stac_eo_mcp.indices import INDEX_DEFINITIONS, compute_index, compute_stats, to_reflectance
16
+ from stac_eo_mcp.models import (
17
+ BBox,
18
+ DateRange,
19
+ IndexResult,
20
+ IndexStats,
21
+ SaveFormat,
22
+ SkippedScene,
23
+ SpectralIndex,
24
+ TimeSeriesPoint,
25
+ TimeSeriesResult,
26
+ TimeSeriesSummary,
27
+ )
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ RESOLUTION_M = 10.0
32
+ LOW_VALID_PCT = 20.0
33
+ # Metadata-only search pool for time series; pixels are read for max_scenes at most.
34
+ SEARCH_POOL = 500
35
+ READ_WORKERS = 4
36
+
37
+
38
+ # --- Single scene ---------------------------------------------------------
39
+
40
+
41
+ def check_overlap(item: pystac.Item, bbox: BBox) -> None:
42
+ if item.geometry and not shape(item.geometry).intersects(box(*bbox.as_list())):
43
+ raise InvalidInputError(
44
+ f"The bbox does not overlap scene {item.id}.",
45
+ hint="Use search_scenes with this bbox to find scenes that cover it.",
46
+ )
47
+
48
+
49
+ def index_from_dataset(
50
+ ds: xr.Dataset, item: pystac.Item, asset_keys: dict[str, str], index: SpectralIndex
51
+ ) -> xr.DataArray:
52
+ """Turn a raw-DN window into a masked index DataArray on the same grid."""
53
+ definition = INDEX_DEFINITIONS[index]
54
+ bands = {}
55
+ for name in definition.bands:
56
+ scaling = catalog.band_scaling(item, asset_keys[name])
57
+ bands[name] = to_reflectance(ds[name].values, scaling.offset_dn, scaling.scale)
58
+ scl = ds["scl"].values if "scl" in ds else None
59
+ values = compute_index(index, bands, scl=scl)
60
+ template = ds[definition.a]
61
+ return xr.DataArray(values, coords=template.coords, dims=template.dims, name=index.value)
62
+
63
+
64
+ def index_on_item(
65
+ catalog_name: CatalogName, item: pystac.Item, index: SpectralIndex, bbox: BBox
66
+ ) -> tuple[xr.DataArray, IndexStats]:
67
+ """Read the bbox window of one scene and compute the masked index."""
68
+ check_overlap(item, bbox)
69
+ keys = catalog.band_asset_keys(catalog_name, item, [*INDEX_DEFINITIONS[index].bands, "scl"])
70
+ sign = catalog.get_config(catalog_name).sign
71
+ ds = raster.load_window(item, keys, bbox, resolution=RESOLUTION_M, sign=sign)
72
+ index_da = index_from_dataset(ds, item, keys, index)
73
+ return index_da, compute_stats(index_da.values)
74
+
75
+
76
+ def compute_index_for_item(
77
+ settings: Settings,
78
+ catalog_name: CatalogName,
79
+ item_id: str,
80
+ index: SpectralIndex,
81
+ bbox: BBox,
82
+ save: SaveFormat = SaveFormat.NONE,
83
+ ) -> IndexResult:
84
+ bbox.check_area(settings.max_area_km2)
85
+ cfg = catalog.get_config(catalog_name)
86
+ item = catalog.get_item(catalog_name, cfg.s2_collection, item_id)
87
+ index_da, stats = index_on_item(catalog_name, item, index, bbox)
88
+
89
+ outputs: dict[str, str] = {}
90
+ if save is not SaveFormat.NONE and stats.valid_pixels:
91
+ stem = settings.output_dir / raster.output_stem(item.id, index, bbox)
92
+ if save in (SaveFormat.PNG, SaveFormat.BOTH):
93
+ outputs["png"] = str(raster.save_png(index_da.values, index, stem.with_suffix(".png")))
94
+ if save in (SaveFormat.GEOTIFF, SaveFormat.BOTH):
95
+ outputs["geotiff"] = str(raster.save_geotiff(index_da, stem.with_suffix(".tif")))
96
+
97
+ return IndexResult(
98
+ item_id=item.id,
99
+ index=index,
100
+ datetime=catalog.summarize_item(item, catalog_name).datetime,
101
+ bbox=bbox.as_list(),
102
+ stats=stats,
103
+ resolution_m=RESOLUTION_M,
104
+ outputs=outputs,
105
+ note=_quality_note(stats.valid_pixel_pct),
106
+ )
107
+
108
+
109
+ def _quality_note(valid_pct: float) -> str | None:
110
+ if valid_pct == 0:
111
+ return (
112
+ "All pixels are masked (cloud, shadow or no data) over this bbox, so no statistics "
113
+ "and no files. Try another scene from search_scenes with a lower max_cloud_cover."
114
+ )
115
+ if valid_pct < LOW_VALID_PCT:
116
+ return (
117
+ f"Only {valid_pct:.1f}% of pixels are clear; the statistics may not be "
118
+ "representative. Consider another scene."
119
+ )
120
+ return None
121
+
122
+
123
+ # --- Time series ----------------------------------------------------------
124
+
125
+
126
+ def _day(item: pystac.Item) -> str:
127
+ return item.datetime.date().isoformat() if item.datetime else "unknown"
128
+
129
+
130
+ def one_per_day(items: list[pystac.Item], bbox: BBox) -> list[pystac.Item]:
131
+ """Keep one scene per acquisition day, oldest first.
132
+
133
+ Areas on a tile overlap are covered by two items per pass. Pick the one
134
+ whose footprint covers most of the bbox, then the lower cloud cover.
135
+ """
136
+ target = box(*bbox.as_list())
137
+ by_day: dict[str, list[pystac.Item]] = defaultdict(list)
138
+ for item in items:
139
+ by_day[_day(item)].append(item)
140
+
141
+ def rank(item: pystac.Item) -> tuple[float, float]:
142
+ covered = shape(item.geometry).intersection(target).area if item.geometry else 0.0
143
+ cloud = item.properties.get("eo:cloud_cover", 100.0)
144
+ return (-round(covered / target.area, 3), cloud)
145
+
146
+ return [min(group, key=rank) for _, group in sorted(by_day.items())]
147
+
148
+
149
+ def sample_evenly(items: list[pystac.Item], n: int) -> list[pystac.Item]:
150
+ """Pick n items spread across the list, always keeping first and last."""
151
+ if len(items) <= n:
152
+ return items
153
+ idx = np.unique(np.linspace(0, len(items) - 1, n).round().astype(int))
154
+ return [items[i] for i in idx]
155
+
156
+
157
+ def time_series(
158
+ settings: Settings,
159
+ catalog_name: CatalogName,
160
+ index: SpectralIndex,
161
+ bbox: BBox,
162
+ date_range: DateRange,
163
+ max_cloud_cover: float | None = None,
164
+ ) -> TimeSeriesResult:
165
+ bbox.check_area(settings.max_area_km2)
166
+ cfg = catalog.get_config(catalog_name)
167
+ found = catalog.search_items(
168
+ catalog_name, cfg.s2_collection, bbox, date_range, max_cloud_cover, limit=SEARCH_POOL
169
+ )
170
+ daily = one_per_day(found, bbox)
171
+ selected = sample_evenly(daily, settings.max_scenes)
172
+ log.info("time_series: %d scenes, %d dates, reading %d", len(found), len(daily), len(selected))
173
+
174
+ def analyse(item: pystac.Item) -> TimeSeriesPoint | SkippedScene:
175
+ try:
176
+ _, stats = index_on_item(catalog_name, item, index, bbox)
177
+ except StacMcpError as exc:
178
+ return SkippedScene(date=_day(item), item_id=item.id, reason=exc.message)
179
+ except Exception as exc: # one bad scene must not sink the series
180
+ log.warning("time_series: %s failed: %s", item.id, exc)
181
+ return SkippedScene(date=_day(item), item_id=item.id, reason=f"read error: {exc}")
182
+ if stats.mean is None or stats.valid_pixel_pct < LOW_VALID_PCT:
183
+ return SkippedScene(
184
+ date=_day(item),
185
+ item_id=item.id,
186
+ reason=f"only {stats.valid_pixel_pct:.1f}% clear pixels over the bbox",
187
+ )
188
+ return TimeSeriesPoint(
189
+ date=_day(item),
190
+ item_id=item.id,
191
+ mean=stats.mean,
192
+ std=stats.std,
193
+ valid_pixel_pct=stats.valid_pixel_pct,
194
+ )
195
+
196
+ with ThreadPoolExecutor(max_workers=READ_WORKERS) as pool:
197
+ results = list(pool.map(analyse, selected))
198
+
199
+ points = [r for r in results if isinstance(r, TimeSeriesPoint)]
200
+ skipped = [r for r in results if isinstance(r, SkippedScene)]
201
+ return TimeSeriesResult(
202
+ index=index,
203
+ bbox=bbox.as_list(),
204
+ date_range=f"{date_range.start}/{date_range.end}",
205
+ scenes_found=len(found),
206
+ dates_analysed=len(selected),
207
+ points=points,
208
+ skipped=skipped,
209
+ summary=_summarize(points),
210
+ note=_series_note(len(daily), len(selected), points, settings.max_scenes),
211
+ )
212
+
213
+
214
+ def _summarize(points: list[TimeSeriesPoint]) -> TimeSeriesSummary | None:
215
+ if not points:
216
+ return None
217
+ return TimeSeriesSummary(
218
+ mean_of_means=round(float(np.mean([p.mean for p in points])), 4),
219
+ min=min(points, key=lambda p: p.mean),
220
+ max=max(points, key=lambda p: p.mean),
221
+ change=round(points[-1].mean - points[0].mean, 4),
222
+ )
223
+
224
+
225
+ def _series_note(
226
+ n_dates: int, n_selected: int, points: list[TimeSeriesPoint], max_scenes: int
227
+ ) -> str | None:
228
+ notes = []
229
+ if n_selected < n_dates:
230
+ notes.append(
231
+ f"{n_dates} acquisition dates matched; {n_selected} were sampled evenly "
232
+ f"(limit {max_scenes}). Narrow the date range for every date."
233
+ )
234
+ if not points:
235
+ notes.append(
236
+ "No date had enough clear pixels. Raise max_cloud_cover, widen the date range "
237
+ "or move the bbox."
238
+ )
239
+ elif len(points) < 3:
240
+ notes.append("Fewer than 3 usable dates: treat any trend with caution.")
241
+ return " ".join(notes) or None
stac_eo_mcp/catalog.py ADDED
@@ -0,0 +1,353 @@
1
+ """STAC catalog access: clients, search, item lookup and Sentinel-2 band mapping.
2
+
3
+ Supported catalogs:
4
+ - Microsoft Planetary Computer (assets are signed with planetary-computer)
5
+ - Element84 Earth Search (public, no signing)
6
+ """
7
+
8
+ import difflib
9
+ import logging
10
+ import warnings
11
+ from dataclasses import dataclass
12
+ from functools import lru_cache
13
+ from typing import Any
14
+
15
+ import planetary_computer
16
+ import pystac
17
+ from pystac_client import Client
18
+ from pystac_client.exceptions import APIError
19
+ from shapely.geometry import mapping, shape
20
+
21
+ from stac_eo_mcp.config import CatalogName
22
+ from stac_eo_mcp.errors import CatalogError, DataUnavailableError, InvalidInputError, NoScenesError
23
+ from stac_eo_mcp.indices import S2_BASELINE_OFFSET_DN, S2_SCALE
24
+ from stac_eo_mcp.models import (
25
+ AssetInfo,
26
+ BBox,
27
+ CollectionSummary,
28
+ DateRange,
29
+ SceneDetails,
30
+ SceneSummary,
31
+ )
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ # Earth Search items trigger a harmless pystac storage-extension migration warning.
36
+ warnings.filterwarnings("ignore", message="Could not parse bucket/account from href")
37
+
38
+ # Canonical band name -> asset key, per catalog.
39
+ CANONICAL_BANDS = ("blue", "green", "red", "nir", "swir16", "swir22", "scl")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CatalogConfig:
44
+ name: CatalogName
45
+ url: str
46
+ title: str
47
+ s2_collection: str
48
+ band_assets: dict[str, str]
49
+ preview_asset: str
50
+ sign: bool = False
51
+
52
+
53
+ CATALOGS: dict[CatalogName, CatalogConfig] = {
54
+ CatalogName.PLANETARY_COMPUTER: CatalogConfig(
55
+ name=CatalogName.PLANETARY_COMPUTER,
56
+ url="https://planetarycomputer.microsoft.com/api/stac/v1",
57
+ title="Microsoft Planetary Computer",
58
+ s2_collection="sentinel-2-l2a",
59
+ band_assets={
60
+ "blue": "B02",
61
+ "green": "B03",
62
+ "red": "B04",
63
+ "nir": "B08",
64
+ "swir16": "B11",
65
+ "swir22": "B12",
66
+ "scl": "SCL",
67
+ },
68
+ preview_asset="rendered_preview",
69
+ sign=True,
70
+ ),
71
+ CatalogName.EARTH_SEARCH: CatalogConfig(
72
+ name=CatalogName.EARTH_SEARCH,
73
+ url="https://earth-search.aws.element84.com/v1",
74
+ title="Element84 Earth Search",
75
+ s2_collection="sentinel-2-c1-l2a",
76
+ band_assets={b: b for b in CANONICAL_BANDS},
77
+ preview_asset="thumbnail",
78
+ ),
79
+ }
80
+
81
+ # Short names an agent is likely to use for Sentinel-2 L2A.
82
+ S2_ALIASES = {"sentinel-2", "sentinel2", "s2", "sentinel-2-l2a", "s2-l2a", "sentinel-2-c1-l2a"}
83
+
84
+ # Item properties worth showing an LLM (the rest is noise).
85
+ DETAIL_PROPERTIES = (
86
+ "datetime",
87
+ "platform",
88
+ "constellation",
89
+ "eo:cloud_cover",
90
+ "s2:mgrs_tile",
91
+ "mgrs:utm_zone",
92
+ "proj:code",
93
+ "proj:epsg",
94
+ "s2:processing_baseline",
95
+ "s2:nodata_pixel_percentage",
96
+ "s2:vegetation_percentage",
97
+ "s2:water_percentage",
98
+ "s2:snow_ice_percentage",
99
+ "s2:high_proba_clouds_percentage",
100
+ "s2:cloud_shadow_percentage",
101
+ "sat:relative_orbit",
102
+ "sat:orbit_state",
103
+ "view:sun_elevation",
104
+ )
105
+
106
+
107
+ def get_config(catalog: CatalogName | str) -> CatalogConfig:
108
+ try:
109
+ return CATALOGS[CatalogName(catalog)]
110
+ except ValueError:
111
+ options = ", ".join(c.value for c in CatalogName)
112
+ raise InvalidInputError(
113
+ f"Unknown catalog '{catalog}'.", hint=f"Use one of: {options}."
114
+ ) from None
115
+
116
+
117
+ @lru_cache
118
+ def get_client(catalog: CatalogName, signed: bool = True) -> Client:
119
+ """STAC client; `signed` signs Planetary Computer asset URLs in results.
120
+
121
+ Listing collections must use signed=False: signing every collection costs one
122
+ token request each, which takes ~60 s on Planetary Computer instead of <1 s.
123
+ """
124
+ cfg = get_config(catalog)
125
+ modifier = planetary_computer.sign_inplace if cfg.sign and signed else None
126
+ try:
127
+ log.info("Opening STAC catalog %s (signed=%s)", cfg.url, modifier is not None)
128
+ return Client.open(cfg.url, modifier=modifier)
129
+ except Exception as exc:
130
+ raise CatalogError(
131
+ f"Could not reach {cfg.title} ({cfg.url}): {exc}",
132
+ hint="Check the network connection, or try the other catalog.",
133
+ ) from exc
134
+
135
+
136
+ # --- Collections ----------------------------------------------------------
137
+
138
+
139
+ @lru_cache
140
+ def _collections(catalog: CatalogName) -> tuple[CollectionSummary, ...]:
141
+ client = get_client(catalog, signed=False)
142
+ try:
143
+ cols = list(client.get_collections())
144
+ except APIError as exc:
145
+ raise CatalogError(f"Listing collections failed: {exc}") from exc
146
+ return tuple(
147
+ CollectionSummary(id=c.id, title=c.title, description=_first_sentence(c.description))
148
+ for c in sorted(cols, key=lambda c: c.id)
149
+ )
150
+
151
+
152
+ def list_collections(catalog: CatalogName) -> list[CollectionSummary]:
153
+ return list(_collections(catalog))
154
+
155
+
156
+ def resolve_collection(catalog: CatalogName, collection: str) -> str:
157
+ """Map S2 aliases to the catalog's collection and check that it exists."""
158
+ cfg = get_config(catalog)
159
+ collection = collection.strip()
160
+ if collection.lower() in S2_ALIASES:
161
+ return cfg.s2_collection
162
+ ids = [c.id for c in _collections(catalog)]
163
+ if collection in ids:
164
+ return collection
165
+ close = difflib.get_close_matches(collection, ids, n=3, cutoff=0.5)
166
+ hint = f"Did you mean: {', '.join(close)}?" if close else "Call list_collections to see ids."
167
+ raise InvalidInputError(f"Collection '{collection}' is not in {cfg.title}.", hint=hint)
168
+
169
+
170
+ # --- Search and lookup ----------------------------------------------------
171
+
172
+
173
+ def search_items(
174
+ catalog: CatalogName,
175
+ collection: str,
176
+ bbox: BBox,
177
+ date_range: DateRange,
178
+ max_cloud_cover: float | None = None,
179
+ limit: int = 10,
180
+ ) -> list[pystac.Item]:
181
+ """Search scenes, newest first. Raises NoScenesError if none match."""
182
+ collection = resolve_collection(catalog, collection)
183
+ params: dict[str, Any] = {
184
+ "collections": [collection],
185
+ "bbox": bbox.as_list(),
186
+ "datetime": date_range.to_stac(),
187
+ "max_items": limit,
188
+ "sortby": [{"field": "properties.datetime", "direction": "desc"}],
189
+ }
190
+ if max_cloud_cover is not None:
191
+ params["query"] = {"eo:cloud_cover": {"lte": max_cloud_cover}}
192
+ try:
193
+ items = list(get_client(catalog).search(**params).items())
194
+ except APIError as exc:
195
+ raise CatalogError(f"Search failed on {get_config(catalog).title}: {exc}") from exc
196
+ if not items:
197
+ raise _no_scenes(collection, date_range, max_cloud_cover)
198
+ items.sort(key=lambda i: i.datetime or 0, reverse=True)
199
+ return items
200
+
201
+
202
+ def _no_scenes(collection: str, dr: DateRange, max_cc: float | None) -> NoScenesError:
203
+ if max_cc is not None:
204
+ msg = (
205
+ f"No {collection} scenes with cloud cover <= {max_cc:g}% "
206
+ f"between {dr.start} and {dr.end} for this bbox."
207
+ )
208
+ hint = "Raise max_cloud_cover or widen the date range."
209
+ else:
210
+ msg = f"No {collection} scenes between {dr.start} and {dr.end} for this bbox."
211
+ hint = "Widen the date range, or check the collection covers this area and period."
212
+ return NoScenesError(msg, hint=hint)
213
+
214
+
215
+ def get_item(catalog: CatalogName, collection: str, item_id: str) -> pystac.Item:
216
+ collection = resolve_collection(catalog, collection)
217
+ try:
218
+ items = list(
219
+ get_client(catalog).search(collections=[collection], ids=[item_id], max_items=1).items()
220
+ )
221
+ except APIError as exc:
222
+ raise CatalogError(f"Looking up '{item_id}' failed: {exc}") from exc
223
+ if not items:
224
+ raise NoScenesError(
225
+ f"Scene '{item_id}' was not found in {collection} on {get_config(catalog).title}.",
226
+ hint="Use an id returned by search_scenes, with the same catalog.",
227
+ )
228
+ return items[0]
229
+
230
+
231
+ # --- Summaries ------------------------------------------------------------
232
+
233
+
234
+ def summarize_item(item: pystac.Item, catalog: CatalogName) -> SceneSummary:
235
+ cfg = get_config(catalog)
236
+ preview = item.assets.get(cfg.preview_asset)
237
+ return SceneSummary(
238
+ id=item.id,
239
+ datetime=_iso(item),
240
+ cloud_cover=_round(item.properties.get("eo:cloud_cover")),
241
+ bbox=[round(v, 5) for v in item.bbox] if item.bbox else None,
242
+ footprint=simplify_geometry(item.geometry),
243
+ preview_url=preview.href if preview else None,
244
+ )
245
+
246
+
247
+ def describe_item(item: pystac.Item) -> SceneDetails:
248
+ props = {k: item.properties[k] for k in DETAIL_PROPERTIES if item.properties.get(k) is not None}
249
+ props.pop("datetime", None)
250
+ assets = [
251
+ AssetInfo(
252
+ key=key,
253
+ title=a.title,
254
+ media_type=a.media_type,
255
+ roles=a.roles or [],
256
+ gsd=a.extra_fields.get("gsd"),
257
+ )
258
+ for key, a in sorted(item.assets.items())
259
+ ]
260
+ return SceneDetails(
261
+ id=item.id,
262
+ collection=item.collection_id or "",
263
+ datetime=_iso(item),
264
+ bbox=[round(v, 5) for v in item.bbox] if item.bbox else None,
265
+ properties=props,
266
+ assets=assets,
267
+ )
268
+
269
+
270
+ def simplify_geometry(geom: dict[str, Any] | None, tolerance: float = 0.001) -> dict | None:
271
+ """Simplify (~100 m) and round a GeoJSON geometry to keep responses small."""
272
+ if not geom:
273
+ return None
274
+ simple = shape(geom).simplify(tolerance, preserve_topology=True)
275
+ return _round_coords(mapping(simple))
276
+
277
+
278
+ def _round_coords(obj: Any, nd: int = 4) -> Any:
279
+ if isinstance(obj, float):
280
+ return round(obj, nd)
281
+ if isinstance(obj, list | tuple):
282
+ return [_round_coords(o, nd) for o in obj]
283
+ if isinstance(obj, dict):
284
+ return {k: _round_coords(v, nd) for k, v in obj.items()}
285
+ return obj
286
+
287
+
288
+ # --- Sentinel-2 bands -----------------------------------------------------
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class BandScaling:
293
+ scale: float
294
+ offset_dn: float # added to DN before scaling
295
+
296
+
297
+ def band_asset_keys(catalog: CatalogName, item: pystac.Item, bands: list[str]) -> dict[str, str]:
298
+ """Canonical band -> asset key, checking that the item has each asset."""
299
+ cfg = get_config(catalog)
300
+ keys = {b: cfg.band_assets[b] for b in bands}
301
+ missing = [f"{b} ({k})" for b, k in keys.items() if k not in item.assets]
302
+ if missing:
303
+ raise DataUnavailableError(
304
+ f"Scene {item.id} lacks assets {missing}.",
305
+ hint=f"Only Sentinel-2 L2A scenes ({cfg.s2_collection}) are supported for indices.",
306
+ )
307
+ return keys
308
+
309
+
310
+ def band_scaling(item: pystac.Item, asset_key: str) -> BandScaling:
311
+ """DN -> reflectance parameters for a Sentinel-2 L2A band.
312
+
313
+ Prefer raster:bands scale/offset from the asset (Earth Search). Otherwise
314
+ fall back to the processing baseline (Planetary Computer): baseline 04.00
315
+ and later store DN with a +1000 offset.
316
+ """
317
+ raster_bands = item.assets[asset_key].extra_fields.get("raster:bands") or []
318
+ if raster_bands and "scale" in raster_bands[0]:
319
+ scale = float(raster_bands[0]["scale"])
320
+ offset = float(raster_bands[0].get("offset", 0.0))
321
+ return BandScaling(scale=scale, offset_dn=offset / scale)
322
+ baseline = item.properties.get("s2:processing_baseline")
323
+ offset_dn = S2_BASELINE_OFFSET_DN if _baseline_ge(baseline, 4.0) else 0.0
324
+ return BandScaling(scale=S2_SCALE, offset_dn=offset_dn)
325
+
326
+
327
+ def _baseline_ge(baseline: Any, threshold: float) -> bool:
328
+ try:
329
+ return float(baseline) >= threshold
330
+ except (TypeError, ValueError):
331
+ # Unknown baseline: assume modern data, which is the common case since 2022.
332
+ return True
333
+
334
+
335
+ # --- Helpers --------------------------------------------------------------
336
+
337
+
338
+ def _iso(item: pystac.Item) -> str | None:
339
+ return item.datetime.isoformat().replace("+00:00", "Z") if item.datetime else None
340
+
341
+
342
+ def _round(v: Any, nd: int = 2) -> float | None:
343
+ return round(float(v), nd) if v is not None else None
344
+
345
+
346
+ def _first_sentence(text: str | None, max_len: int = 200) -> str | None:
347
+ if not text:
348
+ return None
349
+ text = " ".join(text.split())
350
+ cut = text.find(". ")
351
+ if 0 < cut < max_len:
352
+ return text[: cut + 1]
353
+ return text if len(text) <= max_len else text[: max_len - 1].rstrip() + "…"
stac_eo_mcp/config.py ADDED
@@ -0,0 +1,41 @@
1
+ """Settings read from environment variables (prefix STAC_EO_MCP_)."""
2
+
3
+ from enum import StrEnum
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+
7
+ from pydantic import Field
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class CatalogName(StrEnum):
12
+ PLANETARY_COMPUTER = "planetary-computer"
13
+ EARTH_SEARCH = "earth-search"
14
+
15
+
16
+ class Settings(BaseSettings):
17
+ model_config = SettingsConfigDict(env_prefix="STAC_EO_MCP_", extra="ignore")
18
+
19
+ catalog: CatalogName = Field(
20
+ default=CatalogName.PLANETARY_COMPUTER,
21
+ description="Catalog used when a tool call does not name one.",
22
+ )
23
+ output_dir: Path = Field(
24
+ default=Path.home() / "stac-eo-mcp-output",
25
+ description="Folder where PNG/GeoTIFF outputs are written.",
26
+ )
27
+ max_area_km2: float = Field(
28
+ default=100.0, gt=0, description="Largest bbox accepted for raster reads."
29
+ )
30
+ max_scenes: int = Field(
31
+ default=24, ge=1, le=200, description="Most scenes read by time_series."
32
+ )
33
+ max_search_limit: int = Field(
34
+ default=50, ge=1, le=500, description="Most scenes returned by search_scenes."
35
+ )
36
+ log_level: str = Field(default="INFO", description="Python logging level (to stderr).")
37
+
38
+
39
+ @lru_cache
40
+ def get_settings() -> Settings:
41
+ return Settings()
stac_eo_mcp/errors.py ADDED
@@ -0,0 +1,39 @@
1
+ """Errors meant to be read by an AI agent.
2
+
3
+ Every error carries a short message (what went wrong) and an optional hint
4
+ (what to try next), so the agent can recover without guessing.
5
+ """
6
+
7
+
8
+ class StacMcpError(Exception):
9
+ """Base error. str() gives 'message Hint: hint'."""
10
+
11
+ def __init__(self, message: str, hint: str | None = None) -> None:
12
+ super().__init__(message)
13
+ self.message = message
14
+ self.hint = hint
15
+
16
+ def __str__(self) -> str:
17
+ if self.hint:
18
+ return f"{self.message} Hint: {self.hint}"
19
+ return self.message
20
+
21
+
22
+ class InvalidInputError(StacMcpError):
23
+ """A tool argument is malformed or out of range."""
24
+
25
+
26
+ class AreaTooLargeError(InvalidInputError):
27
+ """The bbox exceeds the configured maximum area."""
28
+
29
+
30
+ class NoScenesError(StacMcpError):
31
+ """A search matched no scenes."""
32
+
33
+
34
+ class CatalogError(StacMcpError):
35
+ """The STAC catalog failed or returned something unusable."""
36
+
37
+
38
+ class DataUnavailableError(StacMcpError):
39
+ """A scene exists but lacks the assets or valid pixels needed."""