planetary-computer-mcp 1.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,11 @@
1
+ """
2
+ Planetary Computer MCP Server.
3
+
4
+ A Model Context Protocol (MCP) server for accessing Microsoft's
5
+ Planetary Computer geospatial data catalog.
6
+ """
7
+
8
+ from planetary_computer_mcp import core, server, tools
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = ["core", "server", "tools"]
@@ -0,0 +1,41 @@
1
+ """
2
+ Core utilities for Planetary Computer MCP server.
3
+ """
4
+
5
+ from planetary_computer_mcp.core.collections import (
6
+ COLLECTION_KEYWORDS,
7
+ COLLECTION_TYPES,
8
+ detect_collection_from_query,
9
+ get_collection_type,
10
+ )
11
+ from planetary_computer_mcp.core.geocoding import place_to_bbox, validate_bbox
12
+ from planetary_computer_mcp.core.stac_client import PlanetaryComputerSTAC, stac_client
13
+ from planetary_computer_mcp.core.zarr_utils import (
14
+ COORD_MAPPINGS,
15
+ DEFAULT_VARIABLES,
16
+ get_available_variables,
17
+ get_zarr_metadata,
18
+ get_zarr_store_url,
19
+ load_and_compute_zarr,
20
+ load_zarr_data,
21
+ save_zarr_subset_as_netcdf,
22
+ )
23
+
24
+ __all__ = [
25
+ "COLLECTION_KEYWORDS",
26
+ "COLLECTION_TYPES",
27
+ "COORD_MAPPINGS",
28
+ "DEFAULT_VARIABLES",
29
+ "PlanetaryComputerSTAC",
30
+ "detect_collection_from_query",
31
+ "get_available_variables",
32
+ "get_collection_type",
33
+ "get_zarr_metadata",
34
+ "get_zarr_store_url",
35
+ "load_and_compute_zarr",
36
+ "load_zarr_data",
37
+ "place_to_bbox",
38
+ "save_zarr_subset_as_netcdf",
39
+ "stac_client",
40
+ "validate_bbox",
41
+ ]
@@ -0,0 +1,112 @@
1
+ """
2
+ Collection mapping and metadata for dataset auto-detection.
3
+ """
4
+
5
+ # Mapping of query keywords to collection IDs
6
+ COLLECTION_KEYWORDS: dict[str, str] = {
7
+ # Optical imagery
8
+ "sentinel-1": "sentinel-1-rtc",
9
+ "sentinel": "sentinel-2-l2a",
10
+ "sentinel-2": "sentinel-2-l2a",
11
+ "satellite": "sentinel-2-l2a", # Default optical
12
+ "naip": "naip",
13
+ "aerial": "naip",
14
+ "landsat": "landsat-c2-l2",
15
+ # DEMs
16
+ "dem": "cop-dem-glo-30",
17
+ "elevation": "cop-dem-glo-30",
18
+ "terrain": "cop-dem-glo-30",
19
+ "copernicus": "cop-dem-glo-30",
20
+ "alos": "alos-dem",
21
+ # Land cover
22
+ "land cover": "esa-worldcover",
23
+ "landcover": "esa-worldcover",
24
+ "lulc": "io-lulc-annual-v02",
25
+ "land use": "io-lulc-annual-v02",
26
+ "worldcover": "esa-worldcover",
27
+ # Vectors
28
+ "building": "ms-buildings",
29
+ "buildings": "ms-buildings",
30
+ "footprint": "ms-buildings",
31
+ # SAR
32
+ "sar": "sentinel-1-rtc",
33
+ "radar": "sentinel-1-rtc",
34
+ # Climate / Weather (Zarr-based)
35
+ "gridmet": "gridmet",
36
+ "terraclimate": "terraclimate",
37
+ "daymet": "daymet-daily-na",
38
+ "climate": "gridmet", # Default to GridMET (CONUS, 4km, 1979-2020)
39
+ "weather": "gridmet",
40
+ "temperature": "gridmet",
41
+ "precipitation": "gridmet",
42
+ }
43
+
44
+
45
+ def detect_collection_from_query(query: str) -> str | None:
46
+ """
47
+ Detect collection ID from natural language query.
48
+
49
+ Parameters
50
+ ----------
51
+ query : str
52
+ User query string
53
+
54
+ Returns
55
+ -------
56
+ str or None
57
+ Collection ID or None if not detected
58
+ """
59
+ query_lower = query.lower()
60
+
61
+ # Direct collection name matches
62
+ for keyword, collection in COLLECTION_KEYWORDS.items():
63
+ if keyword in query_lower:
64
+ return collection
65
+
66
+ # Fallback to Sentinel-2 for general imagery queries
67
+ if any(word in query_lower for word in ["imagery", "image", "satellite", "remote sensing"]):
68
+ return "sentinel-2-l2a"
69
+
70
+ return None
71
+
72
+
73
+ # Collection metadata for tool routing
74
+ COLLECTION_TYPES: dict[str, str] = {
75
+ # Raster (COG-based via STAC)
76
+ "sentinel-2-l2a": "raster",
77
+ "naip": "raster",
78
+ "landsat-c2-l2": "raster",
79
+ "cop-dem-glo-30": "raster",
80
+ "alos-dem": "raster",
81
+ "esa-worldcover": "raster",
82
+ "io-lulc-annual-v02": "raster",
83
+ "sentinel-1-rtc": "raster",
84
+ # Zarr-based climate/weather data
85
+ "gridmet": "zarr",
86
+ "terraclimate": "zarr",
87
+ "daymet-daily-na": "zarr",
88
+ "daymet-daily-hi": "zarr",
89
+ "daymet-daily-pr": "zarr",
90
+ "daymet-monthly-na": "zarr",
91
+ "daymet-annual-na": "zarr",
92
+ "era5-pds": "zarr",
93
+ # Vector (GeoParquet)
94
+ "ms-buildings": "vector",
95
+ }
96
+
97
+
98
+ def get_collection_type(collection_id: str) -> str:
99
+ """
100
+ Get the data type for a collection.
101
+
102
+ Parameters
103
+ ----------
104
+ collection_id : str
105
+ Collection ID
106
+
107
+ Returns
108
+ -------
109
+ str
110
+ "raster", "vector", or "zarr"
111
+ """
112
+ return COLLECTION_TYPES.get(collection_id, "raster") # Default to raster
@@ -0,0 +1,79 @@
1
+ """
2
+ Geocoding utilities for converting place names to bounding boxes.
3
+ """
4
+
5
+ from geopy.extra.rate_limiter import RateLimiter
6
+ from geopy.geocoders import Nominatim
7
+
8
+
9
+ def place_to_bbox(place_name: str) -> list[float]:
10
+ """
11
+ Convert place name to [west, south, east, north] bbox.
12
+
13
+ Examples:
14
+ "San Antonio" → [-98.79, 29.22, -98.28, 29.76]
15
+ "New York City" → [-74.26, 40.50, -73.70, 40.92]
16
+
17
+ Parameters
18
+ ----------
19
+ place_name : str
20
+ Human-readable place name
21
+
22
+ Returns
23
+ -------
24
+ list[float]
25
+ Bounding box as [west, south, east, north]
26
+
27
+ Raises
28
+ ------
29
+ ValueError
30
+ If geocoding fails
31
+ """
32
+ geolocator = Nominatim(user_agent="planetary-computer-mcp")
33
+ geocode = RateLimiter(geolocator.geocode, min_delay_seconds=1)
34
+ location = geocode(place_name, exactly_one=True)
35
+
36
+ if location and hasattr(location, "raw") and location.raw.get("boundingbox"):
37
+ bb = location.raw["boundingbox"]
38
+ # boundingbox is [south, north, west, east] as strings
39
+ return [float(bb[2]), float(bb[0]), float(bb[3]), float(bb[1])]
40
+
41
+ raise ValueError(f"Could not geocode: {place_name}")
42
+
43
+
44
+ def validate_bbox(bbox: list[float]) -> list[float]:
45
+ """
46
+ Validate and normalize a bounding box.
47
+
48
+ Parameters
49
+ ----------
50
+ bbox : list[float]
51
+ [west, south, east, north]
52
+
53
+ Returns
54
+ -------
55
+ list[float]
56
+ Normalized bbox
57
+
58
+ Raises
59
+ ------
60
+ ValueError
61
+ If bbox is invalid
62
+ """
63
+ if len(bbox) != 4:
64
+ raise ValueError("Bbox must have 4 elements [west, south, east, north]")
65
+
66
+ west, south, east, north = bbox
67
+
68
+ if west >= east:
69
+ raise ValueError("West must be less than east")
70
+ if south >= north:
71
+ raise ValueError("South must be less than north")
72
+
73
+ # Basic bounds checking
74
+ if not (-180 <= west <= 180 and -180 <= east <= 180):
75
+ raise ValueError("Longitude must be between -180 and 180")
76
+ if not (-90 <= south <= 90 and -90 <= north <= 90):
77
+ raise ValueError("Latitude must be between -90 and 90")
78
+
79
+ return [west, south, east, north]
@@ -0,0 +1,278 @@
1
+ """
2
+ Raster utilities using odc-stac for COG loading and processing.
3
+ """
4
+
5
+ import contextlib
6
+ from typing import Any
7
+
8
+ import rioxarray # noqa: F401
9
+ import xarray as xr
10
+ from odc.stac import load
11
+ from pystac import Item
12
+
13
+
14
+ def load_raster_from_stac(
15
+ items: list[Item],
16
+ bbox: list[float] | None = None,
17
+ bands: list[str] | None = None,
18
+ resolution: float | None = None,
19
+ crs: str = "EPSG:4326",
20
+ ) -> xr.Dataset:
21
+ """
22
+ Load raster data from STAC items using odc-stac.
23
+
24
+ Parameters
25
+ ----------
26
+ items : list[Item]
27
+ Signed STAC items
28
+ bbox : list[float] or None, optional
29
+ Bounding box [west, south, east, north]
30
+ bands : list[str] or None, optional
31
+ Specific bands to load (None for all)
32
+ resolution : float or None, optional
33
+ Output resolution in CRS units
34
+ crs : str, optional
35
+ Output CRS (default EPSG:4326)
36
+
37
+ Returns
38
+ -------
39
+ xr.Dataset
40
+ Xarray Dataset with raster data
41
+ """
42
+ load_kwargs: dict[str, Any] = {
43
+ "crs": crs,
44
+ }
45
+
46
+ if bands:
47
+ load_kwargs["bands"] = bands
48
+
49
+ # Resolution must be provided - caller should pass native resolution
50
+ if resolution:
51
+ load_kwargs["resolution"] = resolution
52
+ else:
53
+ raise ValueError("Resolution is required for odc-stac loading")
54
+
55
+ # Bbox must be passed to odc-stac load() for proper clipping
56
+ if bbox:
57
+ load_kwargs["bbox"] = bbox
58
+
59
+ # Load with odc-stac
60
+ return load(items, **load_kwargs)
61
+
62
+
63
+ def load_multiband_asset(
64
+ items: list[Item],
65
+ asset_name: str,
66
+ bbox: list[float] | None = None,
67
+ resolution: float | None = None,
68
+ band_names: list[str] | None = None,
69
+ ) -> xr.Dataset:
70
+ """
71
+ Load a multi-band asset (like NAIP 'image') using rioxarray.
72
+
73
+ odc-stac doesn't handle multi-band single-asset collections well,
74
+ so we use rioxarray directly for these cases.
75
+
76
+ Parameters
77
+ ----------
78
+ items : list[Item]
79
+ Signed STAC items (uses first item only)
80
+ asset_name : str
81
+ Name of the asset to load (e.g., 'image')
82
+ bbox : list[float] or None, optional
83
+ Bounding box [west, south, east, north] in EPSG:4326
84
+ resolution : float or None, optional
85
+ Output resolution in degrees (ignored - uses native resolution)
86
+ band_names : list[str] or None, optional
87
+ Names for the bands (e.g., ['red', 'green', 'blue', 'nir'])
88
+
89
+ Returns
90
+ -------
91
+ xr.Dataset
92
+ Xarray Dataset with named bands
93
+ """
94
+ import rioxarray as rxr # Local import for type checking
95
+ from rasterio.crs import CRS # type: ignore[import-not-found]
96
+ from rasterio.warp import transform_bounds # type: ignore[import-not-found]
97
+
98
+ if not items:
99
+ raise ValueError("No items provided")
100
+
101
+ item = items[0] # Use first item
102
+ if asset_name not in item.assets:
103
+ raise ValueError(f"Asset '{asset_name}' not found in item")
104
+
105
+ href = item.assets[asset_name].href
106
+
107
+ # Load with rioxarray (supports COGs with windowed reads)
108
+ data = rxr.open_rasterio(href)
109
+ if not isinstance(data, xr.DataArray):
110
+ raise TypeError("Expected DataArray from rioxarray")
111
+
112
+ # Clip and reproject if bbox provided
113
+ if bbox:
114
+ native_crs = data.rio.crs
115
+
116
+ # Transform bbox from WGS84 to native CRS for clipping
117
+ if native_crs and str(native_crs) != "EPSG:4326":
118
+ west, south, east, north = bbox
119
+ native_bounds = transform_bounds(
120
+ CRS.from_epsg(4326),
121
+ native_crs,
122
+ west,
123
+ south,
124
+ east,
125
+ north,
126
+ )
127
+ # Clip in native CRS (preserves native resolution)
128
+ with contextlib.suppress(Exception):
129
+ data = data.rio.clip_box(*native_bounds)
130
+
131
+ # Reproject to WGS84 after clipping
132
+ data = data.rio.reproject("EPSG:4326")
133
+ else:
134
+ # Already in WGS84, just clip
135
+ with contextlib.suppress(Exception):
136
+ data = data.rio.clip_box(*bbox)
137
+
138
+ # Convert to Dataset with named bands
139
+ num_bands = data.shape[0] if len(data.shape) > 2 else 1
140
+ if band_names and len(band_names) <= num_bands:
141
+ # Create dataset with named bands
142
+ datasets = {}
143
+ for i, name in enumerate(band_names):
144
+ if i < num_bands:
145
+ band_data = data.isel(band=i) if len(data.shape) > 2 else data
146
+ datasets[name] = band_data.drop_vars("band", errors="ignore")
147
+ return xr.Dataset(datasets)
148
+ else:
149
+ # Return as single variable
150
+ return xr.Dataset({"data": data})
151
+
152
+
153
+ def save_raster_as_geotiff(
154
+ data: xr.Dataset,
155
+ output_path: str,
156
+ nodata: float | None = None,
157
+ ) -> str:
158
+ """
159
+ Save raster data as GeoTIFF.
160
+
161
+ Parameters
162
+ ----------
163
+ data : xr.Dataset
164
+ Xarray Dataset
165
+ output_path : str
166
+ Output file path
167
+ nodata : float or None, optional
168
+ NoData value
169
+
170
+ Returns
171
+ -------
172
+ str
173
+ Path to saved file
174
+ """
175
+ # Handle temporal data - take the most recent if multiple time slices
176
+ if "time" in data.sizes and data.sizes["time"] > 1:
177
+ # Take the last (most recent) time slice
178
+ data = data.isel(time=-1)
179
+ elif "time" in data.sizes and data.sizes["time"] == 1:
180
+ # Remove singleton time dimension
181
+ data = data.isel(time=0)
182
+
183
+ # For multi-band data, save as multi-band GeoTIFF
184
+ # For single band, extract the DataArray
185
+ if isinstance(data, xr.Dataset) and len(data.data_vars) == 1:
186
+ data_array = data[next(iter(data.data_vars.keys()))]
187
+ elif isinstance(data, xr.Dataset):
188
+ # Multi-band - convert to DataArray with band dimension
189
+ data_array = data.to_array(dim="band")
190
+ else:
191
+ data_array = data
192
+
193
+ # Ensure the data has proper CRS and transform
194
+ if not hasattr(data_array, "rio") or data_array.rio.crs is None:
195
+ # Assume WGS84 if no CRS
196
+ data_array = data_array.rio.write_crs("EPSG:4326")
197
+
198
+ # Set nodata if provided
199
+ if nodata is not None:
200
+ data_array = data_array.rio.write_nodata(nodata)
201
+
202
+ # Save as GeoTIFF
203
+ data_array.rio.to_raster(output_path)
204
+
205
+ return output_path
206
+
207
+
208
+ def get_raster_metadata(data: xr.Dataset) -> dict[str, Any]:
209
+ """
210
+ Extract metadata from raster Dataset.
211
+
212
+ Parameters
213
+ ----------
214
+ data : xr.Dataset
215
+ Xarray Dataset
216
+
217
+ Returns
218
+ -------
219
+ dict[str, Any]
220
+ Dictionary with metadata
221
+ """
222
+ # Use the first variable for metadata
223
+ if isinstance(data, xr.Dataset) and len(data.data_vars) > 0:
224
+ sample_var = next(iter(data.data_vars.keys()))
225
+ data_array = data[sample_var]
226
+ else:
227
+ data_array = data
228
+
229
+ return {
230
+ "crs": str(data_array.rio.crs)
231
+ if hasattr(data_array, "rio") and data_array.rio.crs
232
+ else None,
233
+ "bounds": data_array.rio.bounds() if hasattr(data_array, "rio") else None,
234
+ "resolution": data_array.rio.resolution() if hasattr(data_array, "rio") else None,
235
+ "shape": data_array.shape,
236
+ "dtype": str(data_array.dtype),
237
+ "bands": list(data.data_vars)
238
+ if isinstance(data, xr.Dataset)
239
+ else [data_array.name]
240
+ if data_array.name
241
+ else ["data"],
242
+ }
243
+
244
+
245
+ def crop_raster_to_bbox(
246
+ data: xr.Dataset,
247
+ bbox: list[float],
248
+ ) -> xr.Dataset:
249
+ """
250
+ Crop raster to bounding box.
251
+
252
+ Parameters
253
+ ----------
254
+ data : xr.Dataset
255
+ Xarray Dataset
256
+ bbox : list[float]
257
+ [west, south, east, north]
258
+
259
+ Returns
260
+ -------
261
+ xr.Dataset
262
+ Cropped Dataset
263
+ """
264
+ west, south, east, north = bbox
265
+
266
+ # Apply to all variables
267
+ cropped = {}
268
+ for var_name in data.data_vars:
269
+ try:
270
+ cropped[var_name] = data[var_name].rio.clip_box(
271
+ west, south, east, north, allow_one_dimensional_raster=True
272
+ )
273
+ except Exception as e:
274
+ # If clipping fails, return original
275
+ print(f"Warning: Clipping failed for {var_name}: {e}")
276
+ cropped[var_name] = data[var_name]
277
+
278
+ return xr.Dataset(cropped, attrs=data.attrs)
@@ -0,0 +1,91 @@
1
+ """
2
+ STAC client wrapper for Planetary Computer.
3
+ """
4
+
5
+ import planetary_computer as pc
6
+ from pystac import Item
7
+ from pystac_client import Client
8
+
9
+
10
+ class PlanetaryComputerSTAC:
11
+ """Wrapper for Planetary Computer STAC operations."""
12
+
13
+ def __init__(self) -> None:
14
+ self.catalog_url = "https://planetarycomputer.microsoft.com/api/stac/v1"
15
+ self.client = Client.open(self.catalog_url)
16
+
17
+ def search_items(
18
+ self,
19
+ collections: list[str],
20
+ bbox: list[float] | None = None,
21
+ datetime: str | None = None,
22
+ max_cloud_cover: int | None = None,
23
+ limit: int | None = None,
24
+ ) -> list[Item]:
25
+ """
26
+ Search for STAC items.
27
+
28
+ Parameters
29
+ ----------
30
+ collections : list[str]
31
+ List of collection IDs
32
+ bbox : list[float] or None, optional
33
+ Bounding box [west, south, east, north]
34
+ datetime : str or None, optional
35
+ ISO8601 datetime range
36
+ max_cloud_cover : int or None, optional
37
+ Maximum cloud cover percentage
38
+ limit : int or None, optional
39
+ Maximum number of items to return
40
+
41
+ Returns
42
+ -------
43
+ list[Item]
44
+ List of signed STAC items
45
+ """
46
+ query_params = {}
47
+ if max_cloud_cover is not None:
48
+ query_params["eo:cloud_cover"] = {"lt": max_cloud_cover}
49
+
50
+ search = self.client.search(
51
+ collections=collections,
52
+ bbox=bbox,
53
+ datetime=datetime,
54
+ query=query_params if query_params else None,
55
+ limit=limit,
56
+ )
57
+
58
+ items = list(search.items())
59
+ return [pc.sign(item) for item in items]
60
+
61
+ def get_collection_info(self, collection_id: str) -> dict:
62
+ """
63
+ Get basic info about a collection.
64
+
65
+ Parameters
66
+ ----------
67
+ collection_id : str
68
+ Collection ID
69
+
70
+ Returns
71
+ -------
72
+ dict
73
+ Dictionary with collection metadata
74
+ """
75
+ collection = self.client.get_collection(collection_id)
76
+ return {
77
+ "id": collection.id,
78
+ "title": collection.title or "",
79
+ "description": collection.description or "",
80
+ "providers": [p.name for p in collection.providers] if collection.providers else [],
81
+ "extent": {
82
+ "temporal": collection.extent.temporal.intervals
83
+ if collection.extent.temporal
84
+ else None,
85
+ "spatial": collection.extent.spatial.bboxes if collection.extent.spatial else None,
86
+ },
87
+ }
88
+
89
+
90
+ # Global instance
91
+ stac_client = PlanetaryComputerSTAC()