Compare commits
No commits in common. "4a5035ca523f877269f7e7fee8db21aabd8015fb" and "d13ba744d316269441b93d694fea096068a295a6" have entirely different histories.
4a5035ca52
...
d13ba744d3
@ -38,9 +38,6 @@ _INIT_RETRY_DELAYS = [2.0, 4.0, 8.0]
|
|||||||
# Maximum image dimensions to prevent OOM from LLM requests
|
# Maximum image dimensions to prevent OOM from LLM requests
|
||||||
MAX_IMAGE_DIMENSION = 4096
|
MAX_IMAGE_DIMENSION = 4096
|
||||||
|
|
||||||
# Content types accepted for legend images (SVG is not API-compatible)
|
|
||||||
_RASTER_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
|
|
||||||
|
|
||||||
|
|
||||||
class _LRUCache(OrderedDict):
|
class _LRUCache(OrderedDict):
|
||||||
"""Simple LRU cache backed by OrderedDict."""
|
"""Simple LRU cache backed by OrderedDict."""
|
||||||
@ -424,22 +421,12 @@ class GIBSClient:
|
|||||||
layer_id: str,
|
layer_id: str,
|
||||||
orientation: str = "horizontal",
|
orientation: str = "horizontal",
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
"""Fetch the pre-rendered legend image for a layer.
|
"""Fetch the pre-rendered legend image for a layer."""
|
||||||
|
|
||||||
GIBS capabilities list SVG legend URLs, but PNG versions exist
|
|
||||||
at the same path. We prefer PNG for broad client compatibility
|
|
||||||
(the Anthropic API and most image viewers don't render SVG).
|
|
||||||
"""
|
|
||||||
layer = self.layer_index.get(layer_id)
|
layer = self.layer_index.get(layer_id)
|
||||||
if layer and layer.legend_url:
|
if layer and layer.legend_url:
|
||||||
url = layer.legend_url
|
|
||||||
if url.endswith(".svg"):
|
|
||||||
url = url[:-4] + ".png"
|
|
||||||
try:
|
try:
|
||||||
resp = await self.http.get(url)
|
resp = await self.http.get(layer.legend_url)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
content_type = resp.headers.get("content-type", "").split(";")[0].strip()
|
|
||||||
if content_type in _RASTER_IMAGE_TYPES:
|
|
||||||
return resp.content
|
return resp.content
|
||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
pass
|
pass
|
||||||
@ -459,8 +446,8 @@ class GIBSClient:
|
|||||||
try:
|
try:
|
||||||
resp = await self.http.get(url, params=params)
|
resp = await self.http.get(url, params=params)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
content_type = resp.headers.get("content-type", "").split(";")[0].strip()
|
content_type = resp.headers.get("content-type", "")
|
||||||
if content_type in _RASTER_IMAGE_TYPES:
|
if content_type.startswith("image/"):
|
||||||
return resp.content
|
return resp.content
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
log.debug("Legend not available for %s: %s", layer_id, exc)
|
log.debug("Legend not available for %s: %s", layer_id, exc)
|
||||||
|
|||||||
@ -10,14 +10,13 @@ FastMCP needs runtime types for Literal, list[str], etc.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from fastmcp.server.context import Context
|
|
||||||
from fastmcp.server.middleware import Middleware
|
from fastmcp.server.middleware import Middleware
|
||||||
from fastmcp.utilities.types import Image
|
|
||||||
|
|
||||||
from mcgibs.capabilities import search_layers
|
from mcgibs.capabilities import search_layers
|
||||||
from mcgibs.client import GIBSClient
|
from mcgibs.client import GIBSClient
|
||||||
@ -307,13 +306,12 @@ async def _resolve_bbox(
|
|||||||
async def get_imagery(
|
async def get_imagery(
|
||||||
layer_id: str,
|
layer_id: str,
|
||||||
date: str,
|
date: str,
|
||||||
ctx: Context,
|
|
||||||
bbox: list[float] | None = None,
|
bbox: list[float] | None = None,
|
||||||
place: str | None = None,
|
place: str | None = None,
|
||||||
width: int = 1024,
|
width: int = 1024,
|
||||||
height: int = 1024,
|
height: int = 1024,
|
||||||
format: str = "jpeg",
|
format: str = "jpeg",
|
||||||
):
|
) -> list[dict]:
|
||||||
"""Fetch GIBS imagery via WMS.
|
"""Fetch GIBS imagery via WMS.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -329,22 +327,22 @@ async def get_imagery(
|
|||||||
|
|
||||||
layer = client.get_layer(layer_id)
|
layer = client.get_layer(layer_id)
|
||||||
if layer is None:
|
if layer is None:
|
||||||
return f"Layer '{layer_id}' not found."
|
return [{"type": "text", "text": f"Layer '{layer_id}' not found."}]
|
||||||
|
|
||||||
await ctx.report_progress(1, 4, "Resolving location...")
|
|
||||||
try:
|
try:
|
||||||
resolved_bbox = await _resolve_bbox(client, bbox, place)
|
resolved_bbox = await _resolve_bbox(client, bbox, place)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return str(exc)
|
return [{"type": "text", "text": str(exc)}]
|
||||||
|
|
||||||
|
image_format = f"image/{format}"
|
||||||
|
|
||||||
await ctx.report_progress(2, 4, "Fetching imagery from GIBS...")
|
|
||||||
image_bytes = await client.get_wms_image(
|
image_bytes = await client.get_wms_image(
|
||||||
layer_id,
|
layer_id,
|
||||||
date,
|
date,
|
||||||
resolved_bbox,
|
resolved_bbox,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
f"image/{format}",
|
image_format,
|
||||||
)
|
)
|
||||||
|
|
||||||
description = (
|
description = (
|
||||||
@ -352,11 +350,18 @@ async def get_imagery(
|
|||||||
f"Region: {place or resolved_bbox.wms_bbox}\n"
|
f"Region: {place or resolved_bbox.wms_bbox}\n"
|
||||||
f"Size: {width}x{height}"
|
f"Size: {width}x{height}"
|
||||||
)
|
)
|
||||||
if layer.has_colormap:
|
|
||||||
description += "\nTip: use explain_layer_colormap to understand what the colors represent."
|
|
||||||
|
|
||||||
await ctx.report_progress(4, 4, "Complete")
|
# If the layer has a colormap, add a hint about explain_colormap
|
||||||
return [description, Image(data=image_bytes, format=format)]
|
if layer.has_colormap:
|
||||||
|
description += "\nTip: use explain_colormap to understand what the colors represent."
|
||||||
|
|
||||||
|
mime = f"image/{format}"
|
||||||
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"type": "text", "text": description},
|
||||||
|
{"type": "image", "data": b64, "mimeType": mime},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@ -367,10 +372,9 @@ async def compare_dates(
|
|||||||
layer_id: str,
|
layer_id: str,
|
||||||
date_before: str,
|
date_before: str,
|
||||||
date_after: str,
|
date_after: str,
|
||||||
ctx: Context,
|
|
||||||
bbox: list[float] | None = None,
|
bbox: list[float] | None = None,
|
||||||
place: str | None = None,
|
place: str | None = None,
|
||||||
):
|
) -> list[dict]:
|
||||||
"""Side-by-side comparison of two dates.
|
"""Side-by-side comparison of two dates.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -384,16 +388,13 @@ async def compare_dates(
|
|||||||
|
|
||||||
layer = client.get_layer(layer_id)
|
layer = client.get_layer(layer_id)
|
||||||
if layer is None:
|
if layer is None:
|
||||||
return f"Layer '{layer_id}' not found."
|
return [{"type": "text", "text": f"Layer '{layer_id}' not found."}]
|
||||||
|
|
||||||
await ctx.report_progress(1, 5, "Resolving location...")
|
|
||||||
try:
|
try:
|
||||||
resolved_bbox = await _resolve_bbox(client, bbox, place)
|
resolved_bbox = await _resolve_bbox(client, bbox, place)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return str(exc)
|
return [{"type": "text", "text": str(exc)}]
|
||||||
|
|
||||||
await ctx.report_progress(2, 5, f"Fetching imagery for {date_before}...")
|
|
||||||
await ctx.report_progress(3, 5, f"Fetching imagery for {date_after}...")
|
|
||||||
composite_bytes = await client.compare_dates(
|
composite_bytes = await client.compare_dates(
|
||||||
layer_id,
|
layer_id,
|
||||||
date_before,
|
date_before,
|
||||||
@ -407,8 +408,11 @@ async def compare_dates(
|
|||||||
f"Region: {place or resolved_bbox.wms_bbox}"
|
f"Region: {place or resolved_bbox.wms_bbox}"
|
||||||
)
|
)
|
||||||
|
|
||||||
await ctx.report_progress(5, 5, "Complete")
|
b64 = base64.b64encode(composite_bytes).decode()
|
||||||
return [description, Image(data=composite_bytes, format="jpeg")]
|
return [
|
||||||
|
{"type": "text", "text": description},
|
||||||
|
{"type": "image", "data": b64, "mimeType": "image/jpeg"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@ -418,12 +422,11 @@ async def compare_dates(
|
|||||||
async def get_imagery_composite(
|
async def get_imagery_composite(
|
||||||
layer_ids: list[str],
|
layer_ids: list[str],
|
||||||
date: str,
|
date: str,
|
||||||
ctx: Context,
|
|
||||||
bbox: list[float] | None = None,
|
bbox: list[float] | None = None,
|
||||||
place: str | None = None,
|
place: str | None = None,
|
||||||
width: int = 1024,
|
width: int = 1024,
|
||||||
height: int = 1024,
|
height: int = 1024,
|
||||||
):
|
) -> list[dict]:
|
||||||
"""Multi-layer composite image.
|
"""Multi-layer composite image.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -437,16 +440,13 @@ async def get_imagery_composite(
|
|||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
if len(layer_ids) > 5:
|
if len(layer_ids) > 5:
|
||||||
return "WMS supports at most 5 layers per composite."
|
return [{"type": "text", "text": "WMS supports at most 5 layers per composite."}]
|
||||||
|
|
||||||
await ctx.report_progress(1, 3, "Resolving location...")
|
|
||||||
try:
|
try:
|
||||||
resolved_bbox = await _resolve_bbox(client, bbox, place)
|
resolved_bbox = await _resolve_bbox(client, bbox, place)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return str(exc)
|
return [{"type": "text", "text": str(exc)}]
|
||||||
|
|
||||||
layer_names = ", ".join(layer_ids)
|
|
||||||
await ctx.report_progress(2, 3, f"Fetching {len(layer_ids)}-layer composite...")
|
|
||||||
image_bytes = await client.get_wms_composite(
|
image_bytes = await client.get_wms_composite(
|
||||||
layer_ids,
|
layer_ids,
|
||||||
date,
|
date,
|
||||||
@ -455,12 +455,16 @@ async def get_imagery_composite(
|
|||||||
height,
|
height,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
layer_names = ", ".join(layer_ids)
|
||||||
description = (
|
description = (
|
||||||
f"Composite: {layer_names}\nDate: {date}\nRegion: {place or resolved_bbox.wms_bbox}"
|
f"Composite: {layer_names}\nDate: {date}\nRegion: {place or resolved_bbox.wms_bbox}"
|
||||||
)
|
)
|
||||||
|
|
||||||
await ctx.report_progress(3, 3, "Complete")
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
return [description, Image(data=image_bytes, format="jpeg")]
|
return [
|
||||||
|
{"type": "text", "text": description},
|
||||||
|
{"type": "image", "data": b64, "mimeType": "image/jpeg"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
@ -486,7 +490,7 @@ async def explain_layer_colormap(layer_id: str) -> str:
|
|||||||
async def get_legend(
|
async def get_legend(
|
||||||
layer_id: str,
|
layer_id: str,
|
||||||
orientation: str = "horizontal",
|
orientation: str = "horizontal",
|
||||||
):
|
) -> list[dict]:
|
||||||
"""Fetch the legend graphic for a layer.
|
"""Fetch the legend graphic for a layer.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -497,9 +501,13 @@ async def get_legend(
|
|||||||
legend_bytes = await client.get_legend_image(layer_id, orientation)
|
legend_bytes = await client.get_legend_image(layer_id, orientation)
|
||||||
|
|
||||||
if legend_bytes is None:
|
if legend_bytes is None:
|
||||||
return f"No legend available for '{layer_id}'."
|
return [{"type": "text", "text": f"No legend available for '{layer_id}'."}]
|
||||||
|
|
||||||
return [f"Legend for {layer_id}", Image(data=legend_bytes, format="png")]
|
b64 = base64.b64encode(legend_bytes).decode()
|
||||||
|
return [
|
||||||
|
{"type": "text", "text": f"Legend for {layer_id}"},
|
||||||
|
{"type": "image", "data": b64, "mimeType": "image/png"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
@ -623,38 +631,6 @@ async def layer_resource(layer_id: str) -> str:
|
|||||||
return layer.model_dump_json(indent=2)
|
return layer.model_dump_json(indent=2)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource("gibs://colormap/{layer_id}")
|
|
||||||
async def colormap_resource(layer_id: str) -> str:
|
|
||||||
"""Natural-language explanation of a layer's colormap."""
|
|
||||||
client = _get_client()
|
|
||||||
return await client.explain_layer_colormap(layer_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource("gibs://dates/{layer_id}")
|
|
||||||
async def dates_resource(layer_id: str) -> str:
|
|
||||||
"""Available date range for a layer."""
|
|
||||||
client = _get_client()
|
|
||||||
layer = client.get_layer(layer_id)
|
|
||||||
if layer is None:
|
|
||||||
return json.dumps({"error": f"Layer '{layer_id}' not found"})
|
|
||||||
|
|
||||||
info: dict = {}
|
|
||||||
if layer.time:
|
|
||||||
info["start"] = layer.time.start
|
|
||||||
info["end"] = layer.time.end
|
|
||||||
info["period"] = layer.time.period
|
|
||||||
info["default"] = layer.time.default
|
|
||||||
|
|
||||||
try:
|
|
||||||
domains = await client.describe_domains(layer_id)
|
|
||||||
if "time_domain" in domains:
|
|
||||||
info["live_time_domain"] = domains["time_domain"]
|
|
||||||
except (httpx.HTTPError, RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return json.dumps(info, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource("gibs://projections")
|
@mcp.resource("gibs://projections")
|
||||||
async def projections_resource() -> str:
|
async def projections_resource() -> str:
|
||||||
"""Available GIBS projections with endpoint information."""
|
"""Available GIBS projections with endpoint information."""
|
||||||
|
|||||||
@ -407,74 +407,6 @@ async def test_client_caches_successful_geocode(capabilities_xml):
|
|||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Legend SVG → PNG rewriting (live API compatibility fix)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_legend_rewrites_svg_to_png(capabilities_xml):
|
|
||||||
"""Legend URLs ending in .svg should be rewritten to .png."""
|
|
||||||
# Modify the capabilities XML so AMSR2's legend URL ends in .svg
|
|
||||||
patched_xml = capabilities_xml.replace(
|
|
||||||
"AMSR2_Sea_Ice_Concentration_12km.png",
|
|
||||||
"AMSR2_Sea_Ice_Concentration_12km.svg",
|
|
||||||
)
|
|
||||||
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
|
||||||
return_value=httpx.Response(200, text=patched_xml)
|
|
||||||
)
|
|
||||||
|
|
||||||
buf = BytesIO()
|
|
||||||
Image.new("RGB", (200, 20), "white").save(buf, format="PNG")
|
|
||||||
png_route = respx.get(url__regex=r".*legends/.*\.png").mock(
|
|
||||||
return_value=httpx.Response(
|
|
||||||
200, content=buf.getvalue(), headers={"content-type": "image/png"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
client = GIBSClient()
|
|
||||||
await client.initialize()
|
|
||||||
|
|
||||||
result = await client.get_legend_image("AMSR2_Sea_Ice_Concentration_12km_Monthly")
|
|
||||||
assert result is not None
|
|
||||||
# Verify the .png URL was requested, not .svg
|
|
||||||
assert png_route.call_count == 1
|
|
||||||
assert ".png" in str(png_route.calls.last.request.url)
|
|
||||||
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_legend_rejects_svg_content(capabilities_xml):
|
|
||||||
"""If the legend URL returns SVG content, it should be rejected."""
|
|
||||||
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
|
||||||
return_value=httpx.Response(200, text=capabilities_xml)
|
|
||||||
)
|
|
||||||
respx.get(url__regex=r".*legends/.*\.png").mock(
|
|
||||||
return_value=httpx.Response(
|
|
||||||
200,
|
|
||||||
content=b'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"...',
|
|
||||||
headers={"content-type": "image/svg+xml"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# WMS fallback also returns non-image
|
|
||||||
respx.get(url__regex=r".*wms\.cgi.*").mock(
|
|
||||||
return_value=httpx.Response(
|
|
||||||
200,
|
|
||||||
text="<ServiceException>not supported</ServiceException>",
|
|
||||||
headers={"content-type": "application/xml"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
client = GIBSClient()
|
|
||||||
await client.initialize()
|
|
||||||
|
|
||||||
result = await client.get_legend_image("AMSR2_Sea_Ice_Concentration_12km_Monthly")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Client.http property guard
|
# Client.http property guard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
"""FastMCP integration tests — tool calls against the real server, HTTP mocked."""
|
"""FastMCP integration tests — tool calls against the real server, HTTP mocked."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import respx
|
import respx
|
||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
from PIL import Image as PILImage
|
|
||||||
|
|
||||||
import mcgibs.server as server_module
|
import mcgibs.server as server_module
|
||||||
from mcgibs.client import GIBSClient
|
from mcgibs.client import GIBSClient
|
||||||
@ -173,129 +171,3 @@ async def test_list_tools(capabilities_xml):
|
|||||||
finally:
|
finally:
|
||||||
await server_module._client.close()
|
await server_module._client.close()
|
||||||
server_module._client = None
|
server_module._client = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Image() return type tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_fake_jpeg() -> bytes:
|
|
||||||
"""Create a tiny valid JPEG for mocking WMS responses."""
|
|
||||||
buf = BytesIO()
|
|
||||||
PILImage.new("RGB", (10, 10), "blue").save(buf, format="JPEG")
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_get_imagery_returns_image_content(capabilities_xml):
|
|
||||||
"""get_imagery returns TextContent + ImageContent (not raw dicts)."""
|
|
||||||
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
|
||||||
return_value=httpx.Response(200, text=capabilities_xml)
|
|
||||||
)
|
|
||||||
respx.get(url__regex=r".*wms\.cgi.*").mock(
|
|
||||||
return_value=httpx.Response(
|
|
||||||
200, content=_make_fake_jpeg(), headers={"content-type": "image/jpeg"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
server_module._client = await _init_mock_client(capabilities_xml)
|
|
||||||
try:
|
|
||||||
async with Client(mcp) as client:
|
|
||||||
result = await client.call_tool(
|
|
||||||
"get_imagery",
|
|
||||||
{
|
|
||||||
"layer_id": "MODIS_Terra_CorrectedReflectance_TrueColor",
|
|
||||||
"date": "2025-06-01",
|
|
||||||
"bbox": [-120.0, 30.0, -110.0, 40.0],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert len(result.content) == 2
|
|
||||||
assert result.content[0].type == "text"
|
|
||||||
assert "Corrected Reflectance" in result.content[0].text
|
|
||||||
assert result.content[1].type == "image"
|
|
||||||
assert result.content[1].mimeType == "image/jpeg"
|
|
||||||
finally:
|
|
||||||
await server_module._client.close()
|
|
||||||
server_module._client = None
|
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_get_legend_returns_image_content(capabilities_xml):
|
|
||||||
"""get_legend returns TextContent + ImageContent for layers with legends."""
|
|
||||||
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
|
||||||
return_value=httpx.Response(200, text=capabilities_xml)
|
|
||||||
)
|
|
||||||
|
|
||||||
fake_png = BytesIO()
|
|
||||||
PILImage.new("RGB", (200, 20), "white").save(fake_png, format="PNG")
|
|
||||||
respx.get(url__regex=r".*legends/.*\.png").mock(
|
|
||||||
return_value=httpx.Response(
|
|
||||||
200, content=fake_png.getvalue(), headers={"content-type": "image/png"}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
server_module._client = await _init_mock_client(capabilities_xml)
|
|
||||||
try:
|
|
||||||
async with Client(mcp) as client:
|
|
||||||
result = await client.call_tool(
|
|
||||||
"get_legend",
|
|
||||||
{"layer_id": "AMSR2_Sea_Ice_Concentration_12km_Monthly"},
|
|
||||||
)
|
|
||||||
assert len(result.content) == 2
|
|
||||||
assert result.content[0].type == "text"
|
|
||||||
assert result.content[1].type == "image"
|
|
||||||
assert result.content[1].mimeType == "image/png"
|
|
||||||
finally:
|
|
||||||
await server_module._client.close()
|
|
||||||
server_module._client = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Dynamic resource tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_list_resources(capabilities_xml):
|
|
||||||
"""Server exposes all expected resources including dynamic templates."""
|
|
||||||
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:
|
|
||||||
templates = await client.list_resource_templates()
|
|
||||||
template_uris = {t.uriTemplate for t in templates}
|
|
||||||
|
|
||||||
assert "gibs://layer/{layer_id}" in template_uris
|
|
||||||
assert "gibs://colormap/{layer_id}" in template_uris
|
|
||||||
assert "gibs://dates/{layer_id}" in template_uris
|
|
||||||
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."""
|
|
||||||
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
|
||||||
return_value=httpx.Response(200, text=capabilities_xml)
|
|
||||||
)
|
|
||||||
respx.get(url__regex=r".*colormaps/v1\.3/.*\.xml").mock(
|
|
||||||
return_value=httpx.Response(200, text=colormap_xml)
|
|
||||||
)
|
|
||||||
|
|
||||||
server_module._client = await _init_mock_client(capabilities_xml)
|
|
||||||
try:
|
|
||||||
async with Client(mcp) as client:
|
|
||||||
result = await client.read_resource(
|
|
||||||
"gibs://colormap/AIRS_L3_Surface_Air_Temperature_Daily_Day"
|
|
||||||
)
|
|
||||||
text = result[0].text
|
|
||||||
assert "Surface Air Temperature" in text
|
|
||||||
assert "K" in text
|
|
||||||
finally:
|
|
||||||
await server_module._client.close()
|
|
||||||
server_module._client = None
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user