diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3edc958 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ryan Malloy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f61361a --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# mcgibs + +NASA satellite imagery for LLMs. + +An [MCP](https://modelcontextprotocol.io/) server that connects language models to [NASA GIBS](https://www.earthdata.nasa.gov/engage/open-data-services-software/earthdata-developer-portal/gibs-api) (Global Imagery Browse Services) — 1000+ satellite visualization layers covering the entire Earth, updated daily. + +**Three pillars:** + +- **Discovery** — search layers by keyword, browse measurement categories, check date availability +- **Imagery** — fetch satellite images by place name and date, compare dates side-by-side, composite multiple layers +- **Interpretation** — natural-language colormap explanations, legend graphics, scientific context + +No API key required. All data is freely available from NASA. + +## Quick Start + +### From PyPI + +```bash +uvx mcgibs +``` + +### Add to Claude Code + +```bash +claude mcp add mcgibs -- uvx mcgibs +``` + +### Local development + +```bash +git clone https://git.supported.systems/rpm/mcgibs.git +cd mcgibs +uv sync --all-extras +uv run mcgibs +``` + +Or add a local dev server to Claude Code: + +```bash +claude mcp add mcgibs-local -- uv run --directory /path/to/mcgibs mcgibs +``` + +## Tools + +| Tool | Description | +|------|-------------| +| `search_gibs_layers` | Search 1000+ layers by keyword, measurement, period, or status | +| `get_layer_info` | Full metadata for a layer — instrument, platform, resolution, dates | +| `list_measurements` | All measurement categories with layer counts | +| `check_layer_dates` | Available date range for a layer (capabilities + live DescribeDomains) | +| `get_imagery` | Fetch satellite imagery by layer, date, and place name or bbox | +| `compare_dates` | Side-by-side comparison of two dates for change detection | +| `get_imagery_composite` | Overlay up to 5 layers into a single composite image | +| `explain_layer_colormap` | Natural-language explanation of what colors represent | +| `get_legend` | Pre-rendered legend graphic for a layer | +| `resolve_place` | Geocode a place name to coordinates and bounding box | +| `build_tile_url` | Construct a direct WMTS tile URL for embedding | + +## Resources + +| URI | Description | +|-----|-------------| +| `gibs://catalog` | Full layer catalog grouped by measurement category | +| `gibs://layer/{layer_id}` | Individual layer metadata as JSON | +| `gibs://colormap/{layer_id}` | Colormap explanation for a layer | +| `gibs://dates/{layer_id}` | Available date range for a layer | +| `gibs://projections` | Supported GIBS projections and endpoints | + +## Prompts + +| Prompt | Parameters | Description | +|--------|------------|-------------| +| `earth_overview` | *(none)* | Introduction to GIBS with suggested explorations | +| `investigate_event` | `event_type`, `location`, `date` | Guided workflow for investigating natural events | +| `satellite_snapshot` | `place`, `date` | Quick satellite view of any location | +| `climate_monitor` | `indicator`, `location`, `start_date`, `end_date` | Track climate changes over time | +| `layer_deep_dive` | `layer_id`, `location`, `date` | Full scientific analysis of a single layer | +| `multi_layer_story` | `topic`, `location`, `date` | Data journalism — composite layers to tell a story | +| `polar_watch` | `pole`, `date`, `compare_date` | Arctic/Antarctic ice and snow monitoring | + +## Example Workflows + +### Investigating a wildfire + +``` +User: Use the investigate_event prompt for the 2025 LA wildfires + +→ search_gibs_layers("fire") +→ search_gibs_layers("true color") +→ check_layer_dates("MODIS_Terra_CorrectedReflectance_TrueColor") +→ get_imagery("MODIS_Terra_CorrectedReflectance_TrueColor", "2025-01-08", place="Los Angeles") +→ get_imagery("MODIS_Fires_Terra", "2025-01-08", place="Los Angeles") +→ compare_dates("MODIS_Terra_CorrectedReflectance_TrueColor", "2025-01-01", "2025-01-10", place="Los Angeles") +→ explain_layer_colormap("MODIS_Fires_Terra") +``` + +### Monitoring sea ice decline + +``` +User: Use climate_monitor to track Arctic sea ice from March to September + +→ search_gibs_layers("sea ice") +→ check_layer_dates("AMSR2_Sea_Ice_Concentration_12km") +→ explain_layer_colormap("AMSR2_Sea_Ice_Concentration_12km") +→ compare_dates("AMSR2_Sea_Ice_Concentration_12km", "2025-03-01", "2025-09-01", bbox=[-180,60,180,90]) +→ get_legend("AMSR2_Sea_Ice_Concentration_12km") +``` + +### Quick look at anywhere on Earth + +``` +User: Use satellite_snapshot for the Great Barrier Reef + +→ resolve_place("Great Barrier Reef") +→ get_imagery("MODIS_Terra_CorrectedReflectance_TrueColor", "2025-06-01", place="Great Barrier Reef") +→ search_gibs_layers("chlorophyll") +→ get_imagery_composite(["MODIS_Terra_CorrectedReflectance_TrueColor", "MODIS_Terra_Chlorophyll_A"], "2025-06-01", place="Great Barrier Reef") +``` + +## Projections + +| EPSG | Description | Use case | +|------|-------------|----------| +| 4326 | Geographic (WGS84) | Default — global coverage, most layers | +| 3857 | Web Mercator | Web map tiles, Leaflet/Mapbox integration | +| 3413 | Arctic Polar Stereographic | Arctic-focused imagery | +| 3031 | Antarctic Polar Stereographic | Antarctic-focused imagery | + +## Development + +```bash +uv sync --all-extras + +# Lint +uv run ruff check src/ tests/ + +# Tests +uv run pytest + +# Build +uv build +``` + +## Architecture + +``` +src/mcgibs/ + server.py MCP server — tools, resources, prompts, middleware + client.py GIBS HTTP client — WMS, WMTS, colormaps, geocoding + capabilities.py WMTS GetCapabilities parser and layer search + colormaps.py Colormap XML parser and natural-language interpreter + models.py Pydantic models — Layer, BBox, GeoResult, ColormapEntry + constants.py API endpoints, projections, tile matrix definitions + geo.py Bounding box math and geocoding helpers +``` + +## License + +[MIT](LICENSE) + +## Links + +- [NASA GIBS](https://www.earthdata.nasa.gov/engage/open-data-services-software/earthdata-developer-portal/gibs-api) +- [GIBS API Documentation](https://nasa-gibs.github.io/gibs-api-docs/) +- [Worldview](https://worldview.earthdata.nasa.gov/) — NASA's browser-based GIBS viewer +- [FastMCP](https://gofastmcp.com/) — the MCP framework powering this server +- [Source](https://git.supported.systems/rpm/mcgibs) diff --git a/pyproject.toml b/pyproject.toml index 5eb4d89..e9b4c6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,14 +2,16 @@ name = "mcgibs" version = "2026.02.18" description = "FastMCP server for NASA Global Imagery Browse Services (GIBS)" -readme = "CLAUDE.md" +readme = "README.md" requires-python = ">=3.12" +license = {text = "MIT"} authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}] keywords = ["nasa", "gibs", "mcp", "satellite", "imagery", "earth-science"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", @@ -21,6 +23,11 @@ dependencies = [ "defusedxml>=0.7.1", ] +[project.urls] +Homepage = "https://git.supported.systems/rpm/mcgibs" +Documentation = "https://nasa-gibs.github.io/gibs-api-docs/" +"Bug Tracker" = "https://git.supported.systems/rpm/mcgibs/issues" + [project.scripts] mcgibs = "mcgibs.server:main" diff --git a/src/mcgibs/server.py b/src/mcgibs/server.py index a5bf7dc..79313dc 100644 --- a/src/mcgibs/server.py +++ b/src/mcgibs/server.py @@ -746,6 +746,251 @@ def earth_overview() -> str: return "\n".join(lines) +@mcp.prompt +def satellite_snapshot( + place: str, + date: str = "today", +) -> str: + """Quick satellite view of any place on Earth. + + Args: + place: Location to view (e.g. "Tokyo", "Amazon Rainforest", "Sahara Desert"). + date: Date to view (YYYY-MM-DD), or "today" for the most recent imagery. + """ + lines = [ + f"Show me a satellite view of {place} on {date}.", + "", + "Follow this workflow:", + "", + f'1. **Geocode**: Use resolve_place to get coordinates for "{place}".', + "", + f"2. **True color first**: Fetch imagery for " + f'"MODIS_Terra_CorrectedReflectance_TrueColor" ' + f"on {date} at that location. This gives a natural-looking view.", + "", + f"3. **Find science layers**: Search for layers relevant to " + f'"{place}" — consider what would be scientifically interesting ' + f"for this region (vegetation for forests, sea surface temperature " + f"for coastal areas, snow cover for mountains, etc.).", + "", + "4. **Check dates**: Verify the science layers have data for " + f"{date} using check_layer_dates.", + "", + "5. **Overlay**: If a relevant science layer is available, " + "use get_imagery_composite to overlay it on the true color base.", + "", + "6. **Interpret**: If the overlaid layer has a colormap, " + "use explain_layer_colormap to describe what the colors mean.", + "", + f"7. **Describe**: Summarize what's visible in the satellite view " + f"of {place} — notable features, weather patterns, land use, " + f"or any phenomena visible from orbit.", + ] + return "\n".join(lines) + + +@mcp.prompt +def climate_monitor( + indicator: str, + location: str, + start_date: str, + end_date: str, +) -> str: + """Track a climate indicator over time at a specific location. + + Args: + indicator: What to monitor (e.g. "sea ice", "vegetation", "temperature", "snow cover"). + location: Region to monitor (e.g. "Arctic", "Amazon Basin", "Greenland"). + start_date: Beginning of monitoring period (YYYY-MM-DD). + end_date: End of monitoring period (YYYY-MM-DD). + """ + lines = [ + f"Monitor {indicator} changes near {location} " + f"from {start_date} to {end_date}.", + "", + "Follow this workflow:", + "", + f'1. **Find layers**: Search for GIBS layers related to "{indicator}". ' + f"Look for layers with colormaps — these encode quantitative data.", + "", + "2. **Check availability**: Use check_layer_dates on the best " + f"matching layer to confirm data exists for both {start_date} " + f"and {end_date}.", + "", + "3. **Understand the scale**: Use explain_layer_colormap to learn " + "what the color values represent (units, range). " + "Also fetch the legend with get_legend for visual reference.", + "", + f"4. **Compare endpoints**: Use compare_dates to create a " + f"side-by-side view of {start_date} vs {end_date} " + f'for "{location}". This immediately shows the magnitude of change.', + "", + "5. **Intermediate snapshots**: Fetch 1-2 additional dates between " + "start and end using get_imagery to show the progression " + "(e.g. mid-point of the range).", + "", + "6. **Synthesize**: Describe the observed trend, referencing " + "specific colormap values where possible. Is the indicator " + "increasing, decreasing, or showing seasonal variation? " + "What does the spatial pattern reveal?", + ] + return "\n".join(lines) + + +@mcp.prompt +def layer_deep_dive( + layer_id: str, + location: str = "global", + date: str = "", +) -> str: + """Full scientific analysis of a single GIBS layer. + + Args: + layer_id: The GIBS layer identifier (e.g. "AIRS_L3_Surface_Air_Temperature_Daily_Day"). + location: Region to focus on, or "global" for worldwide view. + date: Specific date (YYYY-MM-DD), or empty for the most recent available. + """ + location_desc = f'for "{location}"' if location != "global" else "at global scale" + date_desc = f"on {date}" if date else "using the most recent available date" + + lines = [ + f"Do a deep scientific analysis of the {layer_id} layer " + f"{location_desc} {date_desc}.", + "", + "Follow this workflow:", + "", + "1. **Layer metadata**: Use get_layer_info to retrieve full metadata " + f'for "{layer_id}" — instrument, platform, resolution, date range, ' + "and scientific description.", + "", + "2. **Color interpretation**: Use explain_layer_colormap to get a " + "detailed description of what each color represents, including " + "physical units and value ranges. Fetch the legend with get_legend.", + "", + f"3. **Sample imagery**: Use get_imagery to fetch the layer " + f"{location_desc} {date_desc}. If no date was specified, " + "use check_layer_dates to find the latest available date first.", + "", + "4. **Image interpretation**: Walk through what's visible in the " + "image, referencing specific colormap values. Identify notable " + "spatial patterns, anomalies, or features.", + "", + "5. **Scientific context**: Explain what this layer measures, " + "why it matters, what instrument collects the data, and how " + "scientists use it. Connect the visualization to the underlying " + "Earth science.", + ] + return "\n".join(lines) + + +@mcp.prompt +def multi_layer_story( + topic: str, + location: str, + date: str, +) -> str: + """Compose a data-driven narrative by combining multiple GIBS layers. + + Args: + topic: Story theme (e.g. "wildfire smoke", "ocean productivity", "dust storm"). + location: Region of interest. + date: Date for the story (YYYY-MM-DD). + """ + lines = [ + f'Tell the story of "{topic}" near {location} on {date} ' + f"by compositing multiple satellite layers.", + "", + "Follow this workflow:", + "", + f'1. **Find related layers**: Search for 2-4 layers related to "{topic}". ' + f"Choose layers that complement each other — for example, " + f"fire detections + aerosol optical depth + true color for wildfires, " + f"or SST + chlorophyll + true color for ocean events.", + "", + "2. **Verify dates**: Use check_layer_dates on each candidate " + f"to confirm data is available for {date}.", + "", + "3. **Understand each layer**: For each layer with a colormap, " + "use explain_layer_colormap to understand the value encoding.", + "", + "4. **Composite view**: Use get_imagery_composite to overlay " + "the layers into a single image. Put the base layer " + "(true color or reference) first in the list.", + "", + "5. **Individual layers**: Also fetch get_imagery for each layer " + "separately so you can describe what each one contributes.", + "", + "6. **Narrative**: Write a data-journalism style narrative " + "describing how the layers interact and what story they tell " + "together. Reference specific colormap values and spatial " + "patterns. Explain cause and effect where the data supports it.", + ] + return "\n".join(lines) + + +@mcp.prompt +def polar_watch( + pole: str, + date: str, + compare_date: str = "", +) -> str: + """Monitor Arctic or Antarctic ice and snow conditions. + + Args: + pole: Which pole — "arctic" or "antarctic". + date: Primary observation date (YYYY-MM-DD). + compare_date: Optional second date for before/after comparison (YYYY-MM-DD). + """ + if pole.lower().startswith("ant"): + region = "Antarctic" + bbox = "[-180, -90, 180, -60]" + else: + region = "Arctic" + bbox = "[-180, 60, 180, 90]" + + lines = [ + f"Monitor {region} ice and snow conditions on {date}.", + "", + "Follow this workflow:", + "", + f'1. **Find polar layers**: Search for "sea ice", "snow", ' + f'and "ice sheet" layers. Focus on layers that cover the ' + f"{region} region.", + "", + f"2. **Check availability**: Use check_layer_dates to verify " + f"data exists for {date} on the selected layers.", + "", + f"3. **Polar imagery**: Fetch imagery using the {region} " + f"bounding box {bbox}. Get true color first, then ice/snow " + f"concentration layers.", + "", + "4. **Color interpretation**: Use explain_layer_colormap on each " + "science layer and fetch legends with get_legend to understand " + "concentration values and classifications.", + "", + "5. **Embeddable tiles**: Use build_tile_url to generate a " + "WMTS URL for the primary ice layer — useful for embedding " + "in reports or dashboards.", + ] + + if compare_date: + lines.extend([ + "", + f"6. **Temporal comparison**: Use compare_dates to show " + f"{compare_date} vs {date} side-by-side. Describe the " + f"change in ice extent and concentration between these dates.", + ]) + else: + lines.extend([ + "", + f"6. **Summarize**: Describe the current {region} ice/snow " + f"state visible in the imagery, referencing colormap values " + f"for concentration percentages.", + ]) + + return "\n".join(lines) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Entry point # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/tests/test_tools.py b/tests/test_tools.py index ba4e4d7..3185b7d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -277,6 +277,65 @@ async def test_list_resources(capabilities_xml): server_module._client = None +# --------------------------------------------------------------------------- +# Prompt tests +# --------------------------------------------------------------------------- + + +@respx.mock +async def test_list_prompts(capabilities_xml): + """All expected prompts are registered on the MCP server.""" + respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( + return_value=httpx.Response(200, text=capabilities_xml) + ) + + server_module._client = await _init_mock_client(capabilities_xml) + try: + async with Client(mcp) as client: + prompts = await client.list_prompts() + prompt_names = {p.name for p in prompts} + + expected = { + "investigate_event", + "earth_overview", + "satellite_snapshot", + "climate_monitor", + "layer_deep_dive", + "multi_layer_story", + "polar_watch", + } + + for name in expected: + assert name in prompt_names, f"Missing prompt: {name}" + finally: + await server_module._client.close() + server_module._client = None + + +@respx.mock +async def test_satellite_snapshot_prompt(capabilities_xml): + """satellite_snapshot prompt includes the place name and expected tool references.""" + respx.get(url__regex=r".*WMTSCapabilities\.xml").mock( + return_value=httpx.Response(200, text=capabilities_xml) + ) + + server_module._client = await _init_mock_client(capabilities_xml) + try: + async with Client(mcp) as client: + result = await client.get_prompt( + "satellite_snapshot", {"place": "Tokyo", "date": "2025-06-01"} + ) + text = result.messages[0].content.text + + assert "Tokyo" in text + assert "resolve_place" in text + assert "get_imagery" in text or "get_imagery_composite" in text + assert "MODIS_Terra_CorrectedReflectance_TrueColor" in text + finally: + await server_module._client.close() + server_module._client = None + + @respx.mock async def test_colormap_resource(capabilities_xml, colormap_xml): """gibs://colormap/{layer_id} returns colormap explanation text."""