Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions src/uxarray_mcp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,39 @@ def make_registry(*, profile: Profile = "core") -> ToolRegistry:
return UXarrayApp().prepare_registry(profile=profile)


#: How long a client may cache our ``tools/list`` response, in milliseconds.
#: The tool surface is fixed at startup by the profile and does not change
#: while the server runs, so re-listing on every turn is pure overhead --
#: the catalog was measured at 74% of the payload on short conversations.
#: Five minutes bounds how long a client can hold a stale surface if a
#: future version does start mutating the registry at runtime.
LIST_TOOLS_TTL_MS = 300_000

#: The surface depends only on the profile, not on the user or session, so
#: a shared cache entry is correct.
LIST_TOOLS_CACHE_SCOPE = "public"


def make_mcp_server(*, profile: Profile = "core"):
"""Build a configured MCP server ready for any transport."""
"""Build a configured MCP server ready for any transport.

The ``tools/list`` cache hints are passed only when the installed adapter
understands them. They are a pure optimization, so an adapter that predates
them should serve the same tool surface rather than fail to start.
"""
import inspect

from toolregistry_server.adapters.mcp import route_table_to_mcp_server
from toolregistry_server.route_table import RouteTable

registry = make_registry(profile=profile)
route_table = RouteTable(registry)
return route_table_to_mcp_server(route_table, name="UXarray MCP")

kwargs: dict[str, object] = {"name": "UXarray MCP"}
supported = inspect.signature(route_table_to_mcp_server).parameters
if "list_tools_ttl_ms" in supported:
kwargs["list_tools_ttl_ms"] = LIST_TOOLS_TTL_MS
if "list_tools_cache_scope" in supported:
kwargs["list_tools_cache_scope"] = LIST_TOOLS_CACHE_SCOPE

return route_table_to_mcp_server(route_table, **kwargs)
28 changes: 28 additions & 0 deletions src/uxarray_mcp/provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,37 @@ def attach_provenance(
if validation_summary is not None:
provenance["validation_summary"] = validation_summary
result["_provenance"] = provenance

# Name the operation in the body, not only inside provenance.
#
# ``response_contract`` declares ``operation`` as a required field for
# every family that has a contract, and the published ``outputSchema``
# is compiled from that same declaration. Emitting it only under
# ``_provenance`` left every one of those results failing its own
# contract check, and an SDK that validates ``structuredContent``
# against the schema rejects the reply outright. Set it here, at the
# single point every contracted result already passes through, so the
# promise and the payload cannot drift apart again.
if _has_contract(tool):
result.setdefault("operation", tool)
return result


def _has_contract(tool: str) -> bool:
"""Whether this operation declares a response contract.

Only contracted families gain an ``operation`` field: the contract is
what makes the field a promise, and adding it to results that never
promised it would be noise.
"""
try:
from .response_contract import _CONTRACTS, _normalize

return _normalize(tool) in _CONTRACTS
except Exception: # pragma: no cover - defensive
return False


def attach_scientific_status(
result: dict[str, Any],
*,
Expand Down
22 changes: 22 additions & 0 deletions src/uxarray_mcp/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,28 @@ def _apply_tags(
predefined, custom = _default_tags_for(raw_name, func)
tool.metadata.tags |= predefined
tool.metadata.custom_tags |= custom
_apply_output_schema(tool, raw_name)


def _apply_output_schema(tool: object, raw_name: str) -> None:
"""Publish a declared response shape as MCP ``outputSchema``.

The adapter reads ``metadata.extra['output_schema']`` and forwards it
to clients in ``tools/list``. Only operations that already declare a
response contract get one; the rest stay silent rather than
advertising a shape we have not committed to.
"""
from .typed_results import output_schema_for

schema = output_schema_for(raw_name)
if schema is None:
return
metadata = getattr(tool, "metadata", None)
if metadata is None:
return
if not isinstance(getattr(metadata, "extra", None), dict):
metadata.extra = {}
metadata.extra["output_schema"] = schema


# ---------------------------------------------------------------------------
Expand Down
13 changes: 12 additions & 1 deletion src/uxarray_mcp/tools/orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,24 @@ def _png_meta(items: list[Any]) -> dict[str, Any]:
image_size_bytes = len(base64.b64decode(png_b64))
except Exception:
image_size_bytes = None
return {
out = {
"png_b64": png_b64,
"image_size_bytes": image_size_bytes,
"grid_info": meta.get("grid_info"),
"variable_name": meta.get("variable_name"),
"_provenance": meta.get("_provenance", {}),
}
# A large figure is a resource_link rather than inline bytes, so pass
# the URI along; otherwise the summary reports no image at all for
# exactly the meshes big enough to be interesting.
if png_b64 is None:
uri = meta.get("image_uri") or getattr(img, "uri", None)
if uri is not None:
out["image_uri"] = str(uri)
out["image_delivery"] = "resource_link"
if out["image_size_bytes"] is None:
out["image_size_bytes"] = getattr(img, "size", None)
return out


def analyze_dataset(
Expand Down
55 changes: 47 additions & 8 deletions src/uxarray_mcp/tools/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,41 @@
)
from uxarray_mcp.domain.zonal import compute_zonal_mean_stats
from uxarray_mcp.provenance import attach_provenance
from uxarray_mcp.typed_results import spill_png


def _png_content(png_bytes: bytes, *, plot_type: str) -> tuple[Any, dict[str, Any]]:
"""Return the content block for a rendered PNG, plus a note about it.

Small images are inlined as an ``image`` block, which keeps the common
interactive case a single round trip. A large one is written to the
artifact store and handed back as a ``resource_link`` instead: base64
inflates bytes by a third, and a multi-hundred-kilobyte figure costs
far more of the caller's context than the picture is worth. The caller
fetches it by URI if it actually wants to look.
"""
link = spill_png(png_bytes, plot_type=plot_type)
if link is None:
b64 = base64.b64encode(png_bytes).decode("utf-8")
return (
ImageContent(type="image", data=b64, mimeType="image/png"),
{"image_delivery": "inline"},
)
from mcp.types import ResourceLink

block = ResourceLink(
type="resource_link",
uri=link["uri"],
name=link["name"],
title=link.get("title"),
description=link.get("description"),
mimeType=link["mime_type"],
size=link.get("size"),
)
return block, {
"image_delivery": "resource_link",
"image_uri": link["uri"],
}


def _resolve_plot_paths(
Expand Down Expand Up @@ -86,10 +121,11 @@ def _plot_mesh_local(
grid = load_grid(grid_path)

png_bytes = render_mesh(grid, width=width, height=height)
b64 = base64.b64encode(png_bytes).decode("utf-8")
image_block, delivery = _png_content(png_bytes, plot_type="mesh_wireframe")

result = {
"image_size_bytes": len(png_bytes),
**delivery,
"grid_info": {
"n_face": int(grid.n_face),
"n_node": int(grid.n_node),
Expand Down Expand Up @@ -118,7 +154,7 @@ def _plot_mesh_local(
)

return [
ImageContent(type="image", data=b64, mimeType="image/png"),
image_block,
TextContent(type="text", text=json.dumps(provenance, indent=2)),
]

Expand Down Expand Up @@ -292,7 +328,7 @@ def plot_mesh_geo(
city_scale=city_scale,
)

b64 = base64.b64encode(png_bytes).decode("utf-8")
image_block, delivery = _png_content(png_bytes, plot_type="mesh_geographic")

# ── Build human-readable plot note ───────────────────────────────────────
note = _build_plot_note(
Expand All @@ -301,6 +337,7 @@ def plot_mesh_geo(

result = {
"image_size_bytes": len(png_bytes),
**delivery,
"grid_info": {
"n_face": int(grid.n_face),
"n_node": int(grid.n_node),
Expand Down Expand Up @@ -330,7 +367,7 @@ def plot_mesh_geo(
],
)
return [
ImageContent(type="image", data=b64, mimeType="image/png"),
image_block,
TextContent(type="text", text=note + "\n\n" + json.dumps(provenance, indent=2)),
]

Expand Down Expand Up @@ -651,10 +688,11 @@ def _plot_variable_local(
title=title,
time_index=time_index,
)
b64 = base64.b64encode(png_bytes).decode("utf-8")
image_block, delivery = _png_content(png_bytes, plot_type="variable_polygons")

result = {
"image_size_bytes": len(png_bytes),
**delivery,
"variable_name": variable_name,
"grid_info": {
"n_face": int(uxds.uxgrid.n_face),
Expand Down Expand Up @@ -692,7 +730,7 @@ def _plot_variable_local(
)

return [
ImageContent(type="image", data=b64, mimeType="image/png"),
image_block,
TextContent(type="text", text=json.dumps(provenance, indent=2)),
]

Expand Down Expand Up @@ -816,10 +854,11 @@ def _plot_zonal_mean_local(
line_color=line_color,
title=title,
)
b64 = base64.b64encode(png_bytes).decode("utf-8")
image_block, delivery = _png_content(png_bytes, plot_type="zonal_mean_profile")

result = {
"image_size_bytes": len(png_bytes),
**delivery,
"variable_name": variable_name,
"latitudes": latitudes,
"zonal_mean_values": values,
Expand Down Expand Up @@ -856,6 +895,6 @@ def _plot_zonal_mean_local(
)

return [
ImageContent(type="image", data=b64, mimeType="image/png"),
image_block,
TextContent(type="text", text=json.dumps(provenance, indent=2)),
]
Loading
Loading