Use FastMCP Image type, Context progress, and dynamic resources
Replace hand-rolled base64 dicts with FastMCP's Image() helper in
get_imagery, compare_dates, get_imagery_composite, and get_legend.
FastMCP handles base64 encoding and MIME type automatically.
Add Context progress reporting to imagery tools so MCP clients can
display fetch status (resolving location, fetching imagery, etc).
Add dynamic resources:
gibs://colormap/{layer_id} — colormap explanation text
gibs://dates/{layer_id} — date range + live DescribeDomains
Remove unused base64 import.
This commit is contained in:
parent
d13ba744d3
commit
db6ed2f550
@ -10,13 +10,14 @@ 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
|
||||||
@ -306,12 +307,13 @@ 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:
|
||||||
@ -327,22 +329,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 [{"type": "text", "text": f"Layer '{layer_id}' not found."}]
|
return 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 [{"type": "text", "text": str(exc)}]
|
return 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,
|
||||||
image_format,
|
f"image/{format}",
|
||||||
)
|
)
|
||||||
|
|
||||||
description = (
|
description = (
|
||||||
@ -350,18 +352,11 @@ 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 the layer has a colormap, add a hint about explain_colormap
|
|
||||||
if layer.has_colormap:
|
if layer.has_colormap:
|
||||||
description += "\nTip: use explain_colormap to understand what the colors represent."
|
description += "\nTip: use explain_layer_colormap to understand what the colors represent."
|
||||||
|
|
||||||
mime = f"image/{format}"
|
await ctx.report_progress(4, 4, "Complete")
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
return [description, Image(data=image_bytes, format=format)]
|
||||||
|
|
||||||
return [
|
|
||||||
{"type": "text", "text": description},
|
|
||||||
{"type": "image", "data": b64, "mimeType": mime},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@ -372,9 +367,10 @@ 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:
|
||||||
@ -388,13 +384,16 @@ 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 [{"type": "text", "text": f"Layer '{layer_id}' not found."}]
|
return 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 [{"type": "text", "text": str(exc)}]
|
return 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,
|
||||||
@ -408,11 +407,8 @@ async def compare_dates(
|
|||||||
f"Region: {place or resolved_bbox.wms_bbox}"
|
f"Region: {place or resolved_bbox.wms_bbox}"
|
||||||
)
|
)
|
||||||
|
|
||||||
b64 = base64.b64encode(composite_bytes).decode()
|
await ctx.report_progress(5, 5, "Complete")
|
||||||
return [
|
return [description, Image(data=composite_bytes, format="jpeg")]
|
||||||
{"type": "text", "text": description},
|
|
||||||
{"type": "image", "data": b64, "mimeType": "image/jpeg"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@ -422,11 +418,12 @@ 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:
|
||||||
@ -440,13 +437,16 @@ async def get_imagery_composite(
|
|||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
if len(layer_ids) > 5:
|
if len(layer_ids) > 5:
|
||||||
return [{"type": "text", "text": "WMS supports at most 5 layers per composite."}]
|
return "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 [{"type": "text", "text": str(exc)}]
|
return 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,16 +455,12 @@ 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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
await ctx.report_progress(3, 3, "Complete")
|
||||||
return [
|
return [description, Image(data=image_bytes, format="jpeg")]
|
||||||
{"type": "text", "text": description},
|
|
||||||
{"type": "image", "data": b64, "mimeType": "image/jpeg"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
@ -490,7 +486,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:
|
||||||
@ -501,13 +497,9 @@ 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 [{"type": "text", "text": f"No legend available for '{layer_id}'."}]
|
return f"No legend available for '{layer_id}'."
|
||||||
|
|
||||||
b64 = base64.b64encode(legend_bytes).decode()
|
return [f"Legend for {layer_id}", Image(data=legend_bytes, format="png")]
|
||||||
return [
|
|
||||||
{"type": "text", "text": f"Legend for {layer_id}"},
|
|
||||||
{"type": "image", "data": b64, "mimeType": "image/png"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
@ -631,6 +623,38 @@ 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."""
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
"""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
|
||||||
@ -171,3 +173,129 @@ 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