From 784d5b01c88827eeb41dfe6a222e8e731d5173ac Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 11 Sep 2026 23:44:53 +0100 Subject: [PATCH 1/9] chore: add mcp extra --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 9d35678..5c1e654 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ def get_version(): header_count, long_description = get_description() extras = { + "mcp": ["fastmcp>=2,<5", "pillow"], "gym-v21": ["gym==0.21.0", "pyglet==1.5.11", "numpy<2.0"], "gym-v26": ["gym==0.26.2", "numpy<2.0"], "dm-control": ["dm-control>=1.0.10", "imageio", "h5py>=3.7.0"], From a6b0e5ed543bd49d264223e42593e87b15c57c3e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 12 Sep 2026 00:35:55 +0100 Subject: [PATCH 2/9] feat: MCP adapter for Gymnasium --- setup.py | 3 + shimmy/mcp_adapters/__init__.py | 5 + shimmy/mcp_adapters/gymnasium_interface.py | 204 ++++++++++++ shimmy/mcp_adapters/gymnasium_mcp.py | 87 ++++++ shimmy/mcp_adapters/spaces.py | 271 ++++++++++++++++ tests/test_gymnasium_mcp.py | 342 +++++++++++++++++++++ 6 files changed, 912 insertions(+) create mode 100644 shimmy/mcp_adapters/__init__.py create mode 100644 shimmy/mcp_adapters/gymnasium_interface.py create mode 100644 shimmy/mcp_adapters/gymnasium_mcp.py create mode 100644 shimmy/mcp_adapters/spaces.py create mode 100644 tests/test_gymnasium_mcp.py diff --git a/setup.py b/setup.py index 5c1e654..3a75ffc 100644 --- a/setup.py +++ b/setup.py @@ -69,6 +69,9 @@ def get_version(): install_requires=["numpy>=1.18.0", "gymnasium>=1.0.0"], tests_require=extras["testing"], extras_require=extras, + entry_points={ + "console_scripts": ["gym-mcp=shimmy.mcp_adapters.gymnasium_mcp:main"], + }, classifiers=[ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/shimmy/mcp_adapters/__init__.py b/shimmy/mcp_adapters/__init__.py new file mode 100644 index 0000000..fb10968 --- /dev/null +++ b/shimmy/mcp_adapters/__init__.py @@ -0,0 +1,5 @@ +"""Optional MCP adapters.""" + +from shimmy.mcp_adapters.gymnasium_interface import GymnasiumMCPAdapter + +__all__ = ["GymnasiumMCPAdapter"] diff --git a/shimmy/mcp_adapters/gymnasium_interface.py b/shimmy/mcp_adapters/gymnasium_interface.py new file mode 100644 index 0000000..54615fe --- /dev/null +++ b/shimmy/mcp_adapters/gymnasium_interface.py @@ -0,0 +1,204 @@ +"""Expose a single Gymnasium environment through FastMCP.""" + +import json +from functools import cache +from threading import RLock +from typing import Any, cast + +import gymnasium +import numpy as np + +from shimmy.mcp_adapters.spaces import ( + decode, + describe, + encode, + image_content, + image_layout, + json_value, +) + +try: + from fastmcp import FastMCP + from mcp.types import TextContent + from importlib import import_module + import_module("PIL.Image") +except ImportError as e: + raise ImportError("MCP support requires: pip install 'shimmy[mcp]'") from e + + +class GymnasiumMCPAdapter: + """Wrap an environment with MCP tools, resources, and episode guidance.""" + + def __init__( + self, + env: gymnasium.Env, + name: str | None = None, + *, + max_image_bytes: int = 5_000_000, + **kwargs, + ): + """Create the server. + + Args: + env: Environment owned by the caller. + name: Optional server name override. + max_image_bytes: Maximum base64 bytes per image. + **kwargs: Forwarded to FastMCP. + """ + if max_image_bytes <= 0: + raise ValueError("max_image_bytes must be positive") + self.env = env + self.max_image_bytes = max_image_bytes + self._lock = RLock() + self._closed = False + self.total_reward = 0.0 + env_id = getattr(env.spec, "id", None) or "unknown" + # TODO: improve this default instruction + kwargs.setdefault( + "instructions", + "Read gymnasium://observation_space and gymnasium://action_space, reset, " + "then step with a JSON action until terminated or truncated. sample_action " + "provides valid actions. Image observations need to be check via the " + "`render` tool'. Close when finished.", + ) + self.mcp = FastMCP( + name if name is not None else f"Gymnasium[{env_id}]", **kwargs + ) + for method in ( + self.reset, + self.step, + self.render, + self.close, + self.sample_action, + ): + self.mcp.tool()(method) + self._resource("spec", lambda: env.spec.to_json() if env.spec else "{}") + self._resource( + "metadata", + lambda: json.dumps( + json_value({**env.metadata, "render_mode": env.render_mode}), + allow_nan=False, + ), + ) + self._resource( + "observation_space", + lambda: json.dumps( + describe(env.observation_space, images=True), allow_nan=False + ), + ) + self._resource( + "action_space", + lambda: json.dumps(describe(env.action_space), allow_nan=False), + ) + self.mcp.prompt()(self.play_episode) + + def _resource(self, name, factory): + @cache + def read() -> str: + with self._lock: + return factory() + + self.mcp.resource( + f"gymnasium://{name}", name=name, mime_type="application/json" + )(read) + + def __getattr__(self, name): + """Delegate server methods to FastMCP.""" + return getattr(self.mcp, name) + + def _observation(self, observation, **fields): + result = { + "observation": encode(self.env.observation_space, observation, images=True), + **json_value(fields), + } + return [ + TextContent(type="text", text=json.dumps(result, allow_nan=False)), + ] + + def reset(self, seed: int | None = None, options: dict | None = None) -> Any: + """Start an episode and return its observation and info.""" + with self._lock: + observation, info = self.env.reset(seed=seed, options=options) + self._closed = False + self.total_reward = 0.0 + return self._observation( + observation, info=info, total_reward=self.total_reward + ) + + def step(self, action: Any) -> Any: + """Apply one JSON action and return the transition with render hints for images.""" + with self._lock: + try: + action = decode(self.env.action_space, action) + except ValueError as exc: + return {"error": True, "message": str(exc)} + observation, reward, terminated, truncated, info = self.env.step(action) + self.total_reward += float(reward) + return self._observation( + observation, + reward=reward, + total_reward=self.total_reward, + terminated=terminated, + truncated=truncated, + info=info, + ) + + def render(self) -> Any: + """Return the current render as text, images, or status metadata.""" + with self._lock: + value = self.env.render() + if isinstance(value, str): + return TextContent(type="text", text=value) + is_list_expected = self.env.render_mode and self.env.render_mode.endswith("_list") + frames = value if is_list_expected else [value] + assert frames is not None, f"render_mode of '{self.env.render_mode}' should not produce None" + if all(isinstance(elem, str) for elem in frames): + return TextContent(type="text", text=str(frames)) + content = [ + TextContent( + type="text", text=json.dumps({"render_mode": self.env.render_mode}) + ) + ] + for frame in frames: + layout = ( + image_layout(gymnasium.spaces.Box(0, 255, frame.shape, np.uint8)) + if isinstance(frame, np.ndarray) and frame.dtype == np.uint8 + else None + ) + if layout: + content.append( + image_content(cast(np.typing.NDArray[np.uint8], frame), layout, self.max_image_bytes) + ) + else: + content.append( + TextContent( + type="text", + text=json.dumps( + { + "render_mode": self.env.render_mode, + "data": json_value(frame), + }, + allow_nan=False, + ), + ) + ) + return content + + def close(self) -> dict: + """Close the environment.""" + with self._lock: + if not self._closed: + self.env.close() + self._closed = True + return {"ok": True} + + def sample_action(self) -> Any: + """Sample a valid JSON action.""" + with self._lock: + return encode(self.env.action_space, self.env.action_space.sample()) + + def play_episode(self, goal: str | None = None) -> str: + """Explain how to play one episode.""" + return f"{self.mcp.instructions}\n" + ( + f"Goal: {goal}" if goal else "Play one episode." + ) diff --git a/shimmy/mcp_adapters/gymnasium_mcp.py b/shimmy/mcp_adapters/gymnasium_mcp.py new file mode 100644 index 0000000..2e1def2 --- /dev/null +++ b/shimmy/mcp_adapters/gymnasium_mcp.py @@ -0,0 +1,87 @@ +"""Run a Gymnasium environment over MCP.""" + +import argparse +import importlib +import json + +import gymnasium + +from shimmy.mcp_adapters.gymnasium_interface import GymnasiumMCPAdapter + + +def try_load_json(value: str): + """Attempt to load value as json.""" + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +class GymEnvironmentArgument(argparse.Action): + """Process keyword arguments that should passthrough to ``gym.make``.""" + + def __call__(self, parser, namespace, values, option_string=None): + kwargs = getattr(namespace, "environment_kwargs", None) + if kwargs is None: + kwargs = namespace.environment_kwargs = {} + if self.dest in kwargs: + raise KeyError(f"Duplicate environment argument: {self.dest}") + kwargs[self.dest] = values + + +def main(argv: list[str] | None = None) -> None: + """Start MCP, forwarding extra flags and JSON kwargs to gymnasium.make.""" + parser = argparse.ArgumentParser( + description=__doc__, + allow_abbrev=False, + epilog="Extra --key value or --key=value flags become environment kwargs. " + "Values use JSON types when valid, otherwise strings; bare flags mean true. " + "Hyphens become underscores. Duplicate keys raise KeyError.", + ) + parser.add_argument("-i", "--import", dest="imports", default="") + parser.add_argument( + "-t", "--transport", default="stdio", help="MCP transport (default: stdio)" + ) + parser.add_argument( + "-r", "--render-mode", action=GymEnvironmentArgument, help="Gymnasium render mode" + ) + parser.add_argument( + "--kwargs", + type=json.loads, + default={}, + help="JSON object of gymnasium.make arguments", + ) + parser.add_argument("env_id") + _, unknown = parser.parse_known_args(argv) + for flag in dict.fromkeys(token.split("=", 1)[0] for token in unknown): + if flag.startswith("-") and flag != "--" and isinstance(try_load_json(flag), str): + parser.add_argument( + flag, + dest=flag.lstrip("-").replace("-", "_"), + type=try_load_json, + nargs="?", + const=True, + action=GymEnvironmentArgument, + ) + args = parser.parse_args(argv) + if not isinstance(args.kwargs, dict): + parser.error("--kwargs must be a JSON object") + kwargs = getattr(args, "environment_kwargs", {}) + duplicates = kwargs.keys() & args.kwargs.keys() + if duplicates: + raise KeyError( + f"Duplicate environment arguments: {', '.join(sorted(duplicates))}" + ) + kwargs.update(args.kwargs) + for module in args.imports.split(","): + if module.strip(): + importlib.import_module(module.strip()) + env = gymnasium.make(args.env_id, **kwargs) + try: + GymnasiumMCPAdapter(env).run(transport=args.transport) + finally: + env.close() + + +if __name__ == "__main__": + main() diff --git a/shimmy/mcp_adapters/spaces.py b/shimmy/mcp_adapters/spaces.py new file mode 100644 index 0000000..db1e9ee --- /dev/null +++ b/shimmy/mcp_adapters/spaces.py @@ -0,0 +1,271 @@ +"""Single-sample JSON codecs for Gymnasium spaces.""" + +import base64 +import io +import json +from typing import Any, cast +from mcp.types import ImageContent +from PIL import Image +import gymnasium +import numpy as np +from gymnasium import spaces + + +def json_value(value: Any) -> Any: + """Convert NumPy values to strict JSON values.""" + if isinstance(value, np.ndarray): + return json_value(value.tolist()) + if isinstance(value, np.generic): + return json_value(value.item()) + if isinstance(value, dict): + if not all(isinstance(key, str) for key in value): + raise ValueError("JSON object keys must be strings") + return {key: json_value(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [json_value(item) for item in value] + json.dumps(value, allow_nan=False) + return value + + +def image_layout(space: spaces.Space) -> str | None: + """Identify uint8 images, preferring channels-last for ambiguous shapes.""" + if not isinstance(space, spaces.Box) or space.dtype != np.uint8: + return None + shape = space.shape + if not all(shape): + return None + if len(shape) == 2: + return "HW" + if len(shape) == 3: + if shape[-1] in (1, 3, 4): + return "HWC" + if shape[0] in (1, 3, 4): + return "CHW" + return None + + +def image_content(value: np.ndarray, layout: str, limit: int) -> Any: + """Encode an image as native MCP content within the base64 byte limit.""" + + if layout == "CHW": + value = np.moveaxis(value, 0, -1) + if value.ndim == 3 and value.shape[-1] == 1: + value = value[..., 0] + buffer = io.BytesIO() + Image.fromarray(value).save(buffer, format="PNG") + data = base64.b64encode(buffer.getvalue()).decode("ascii") + if len(data) > limit: + raise ValueError(f"Encoded image exceeds max_image_bytes={limit}") + return ImageContent(type="image", data=data, mimeType="image/png") + + +def encode(space: spaces.Space, value: Any, images: bool = False) -> Any: + """Encode one sample, referring image observations to render.""" + if images and image_layout(space): + return "Check image with `render` tool" + if isinstance(space, spaces.Dict): + return {k: encode(s, value[k], images) for k, s in space.spaces.items()} + if isinstance(space, spaces.Tuple): + return [encode(s, v, images) for s, v in zip(space.spaces, value)] + if isinstance(space, spaces.Sequence): + values = ( + gymnasium.vector.utils.iterate(space.stacked_feature_space, value) + if space.stack + else value + ) + return [encode(space.feature_space, v, images) for v in values] + if isinstance(space, spaces.OneOf): + index, item = value + return { + "index": int(index), + "value": encode(space.spaces[index], item, images), + } + if isinstance(space, spaces.Graph): + assert space.edge_space is not None + return { + "nodes": [encode(space.node_space, v, images) for v in value.nodes], + "edges": ( + None + if value.edges is None + else [encode(space.edge_space, v, images) for v in value.edges] + ), + "edge_links": json_value(value.edge_links), + } + try: + result = json_value(space.to_jsonable([value])[0]) + if not space.contains(space.from_jsonable([result])[0]): + raise ValueError("round trip failed") + return result + except (TypeError, ValueError, KeyError, IndexError, NotImplementedError) as exc: + raise ValueError( + f"Unsupported or invalid {type(space).__name__}: {exc}" + ) from exc + + +def decode(space: spaces.Space, value: Any, path: str = "action") -> Any: + """Decode and validate an action without silently truncating integers.""" + try: + if isinstance(space, spaces.Dict): + if not isinstance(value, dict) or value.keys() != space.spaces.keys(): + raise ValueError("expected exactly the declared keys") + result = { + k: decode(s, value[k], f"{path}.{k}") for k, s in space.spaces.items() + } + elif isinstance(space, spaces.Tuple): + if not isinstance(value, list) or len(value) != len(space.spaces): + raise ValueError("expected an array of the declared length") + result = tuple( + decode(s, v, f"{path}[{i}]") + for i, (s, v) in enumerate(zip(space.spaces, value)) + ) + elif isinstance(space, spaces.Sequence): + if not isinstance(value, list): + raise ValueError("expected an array") + items = [ + decode(space.feature_space, v, f"{path}[{i}]") + for i, v in enumerate(value) + ] + result = ( + gymnasium.vector.utils.concatenate( + space.feature_space, + items, + gymnasium.vector.utils.create_empty_array( + space.feature_space, n=len(items) + ), + ) + if space.stack + else tuple(items) + ) + elif isinstance(space, spaces.OneOf): + if not isinstance(value, dict) or set(value) != {"index", "value"}: + raise ValueError("expected index and value") + index = value["index"] + if not isinstance(index, int) or not 0 <= index < len(space.spaces): + raise ValueError("invalid subspace index") + result = ( + index, + decode(space.spaces[index], value["value"], f"{path}.value"), + ) + elif isinstance(space, spaces.Graph): + if not isinstance(value, dict) or set(value) != { + "nodes", + "edges", + "edge_links", + }: + raise ValueError("expected nodes, edges, and edge_links") + nodes = decode( + spaces.Sequence(space.node_space, stack=True), + value["nodes"], + f"{path}.nodes", + ) + edges = value["edges"] + if edges is not None: + if space.edge_space is None: + raise ValueError("edges are not supported") + edges = decode( + spaces.Sequence(space.edge_space, stack=True), + edges, + f"{path}.edges", + ) + links = value["edge_links"] + if links is not None: + links = np.asarray(links) + if links.size == 0: + links = np.empty((0, 2), dtype=np.int64) + if links.dtype.kind not in "iu": + raise ValueError("edge_links must be integers") + result = spaces.GraphInstance(nodes, edges, links) + elif isinstance( + space, + (spaces.Box, spaces.MultiDiscrete, spaces.MultiBinary, spaces.Discrete), + ): + raw = np.asarray(value) + if raw.dtype.kind not in "iuf" or not np.all(np.isfinite(raw)): + raise ValueError("expected finite numeric values") + if np.issubdtype(space.dtype, np.integer): + bounds = np.iinfo(cast(np.integer, space.dtype)) + if ( + np.any(raw != np.floor(raw)) + or np.any(raw < bounds.min) + or np.any(raw > bounds.max) + ): + raise ValueError("expected representable integers") + result = np.asarray(value, dtype=space.dtype) + if result.shape != space.shape: + raise ValueError(f"expected shape {space.shape}, got {result.shape}") + if isinstance(space, spaces.Discrete): + result = result[()] + else: + result = space.from_jsonable([value])[0] + if not space.contains(result): # pyright: ignore[reportArgumentType,reportGeneralTypeIssues] + raise ValueError("value is outside the space") + return result + except ( + TypeError, + ValueError, + KeyError, + IndexError, + OverflowError, + NotImplementedError, + ) as exc: + raise ValueError( + f"{path}: expected {space!r}; received {str(value)[:160]} ({type(value).__name__}): {exc}" + ) from exc + + +def describe(space: spaces.Space, images: bool = False) -> dict: + """Describe a space using JSON-safe bounds and nested space definitions.""" + result: dict[str, Any] = {"type": type(space).__name__, "repr": repr(space)} + for field in ( + "shape", + "dtype", + "n", + "nvec", + "start", + "min_length", + "max_length", + "stack", + ): + value = getattr(space, field, None) + if value is not None: + result[field] = str(value) if field == "dtype" else json_value(value) + if isinstance(space, spaces.Box): + schema = { + "type": "integer" if np.issubdtype(space.dtype, np.integer) else "number" + } + for length in reversed(space.shape): + schema = { + "type": "array", + "minItems": length, + "maxItems": length, + "items": schema, + } + result["json_schema"] = schema + for field in ("low", "high"): + value = getattr(space, field) + result[field] = np.where( + np.isfinite(value), + value.astype(object), + np.where(value < 0, "-Infinity", "Infinity"), + ).tolist() + layout = image_layout(space) if images else None + result.update(content_type="image" if layout else "json") + if layout: + result.update(mime_type="image/png", channel_order=layout) + result.update( + observation_value="Check image with `render` tool", image_tool="render" + ) + if isinstance(space, spaces.Dict): + result["spaces"] = {k: describe(s, images) for k, s in space.spaces.items()} + elif isinstance(space, (spaces.Tuple, spaces.OneOf)): + result["spaces"] = [describe(s, images) for s in space.spaces] + elif isinstance(space, spaces.Sequence): + result["feature_space"] = describe(space.feature_space, images) + elif isinstance(space, spaces.Graph): + result["node_space"] = describe(space.node_space, images) + result["edge_space"] = ( + None if space.edge_space is None else describe(space.edge_space, images) + ) + elif isinstance(space, spaces.Text): + result["charset"] = sorted(space.character_set) + return result diff --git a/tests/test_gymnasium_mcp.py b/tests/test_gymnasium_mcp.py new file mode 100644 index 0000000..87ebfb3 --- /dev/null +++ b/tests/test_gymnasium_mcp.py @@ -0,0 +1,342 @@ +"""Exercise the MCP adapter through its codecs and in-process client.""" + +import asyncio +import base64 +import builtins +import io +import json + +import gymnasium as gym +import numpy as np +import pytest + +from shimmy.mcp_adapters import GymnasiumMCPAdapter +from shimmy.mcp_adapters import gymnasium_mcp as cli +from shimmy.mcp_adapters.spaces import ( + decode, + describe, + encode, + image_content, + image_layout, +) + +fastmcp = pytest.importorskip("fastmcp") +Image = pytest.importorskip("PIL.Image") + +SPACE_CASES = [ + gym.spaces.Discrete(4, start=2), + gym.spaces.Box(-1, 1, (), dtype=np.float32), + gym.spaces.Box(-1, 1, (2, 3), dtype=np.float32), + gym.spaces.MultiBinary((2, 3)), + gym.spaces.MultiDiscrete([2, 3]), + gym.spaces.Text(10), + gym.spaces.Tuple((gym.spaces.Discrete(2), gym.spaces.Text(5))), + gym.spaces.Dict(a=gym.spaces.Discrete(2), b=gym.spaces.MultiBinary(3)), + gym.spaces.Sequence(gym.spaces.Dict(a=gym.spaces.Discrete(2))), + gym.spaces.Sequence(gym.spaces.Dict(a=gym.spaces.Discrete(2)), stack=True), + gym.spaces.Graph(gym.spaces.Box(-1, 1, (2,)), gym.spaces.Discrete(3)), + gym.spaces.Graph(gym.spaces.Discrete(3), None), +] +if hasattr(gym.spaces, "OneOf"): + SPACE_CASES.append(gym.spaces.OneOf((gym.spaces.Discrete(2), gym.spaces.Text(5)))) + + +@pytest.mark.parametrize("space", SPACE_CASES) +def test_space_roundtrip(space): + """All supported spaces preserve single samples over JSON.""" + space.seed(17) + value = space.sample() + encoded = json.loads(json.dumps(encode(space, value), allow_nan=False)) + assert space.contains(decode(space, encoded)) + assert encode(space, decode(space, encoded)) == encoded + json.dumps(describe(space), allow_nan=False) + + +@pytest.mark.parametrize("value", [1.5, True, "1", -1, 256, [1]]) +def test_invalid_action(value): + """Reject coercions and out-of-space values with a useful path.""" + space = gym.spaces.Dict(a=gym.spaces.Discrete(2)) + with pytest.raises(ValueError, match=r"action.a"): + decode(space, {"a": value}) + + +class Pixels(gym.Env): + """Small deterministic environment for image and state tests.""" + + metadata = {"render_modes": ["rgb_array", "rgb_array_list", "ansi"]} + render_mode = "rgb_array" + + def __init__(self): + """Initialize image spaces and counters.""" + self.action_space = gym.spaces.Discrete(2) + self.observation_space = gym.spaces.Dict( + image=gym.spaces.Box(0, 255, (8, 9, 3), np.uint8), + count=gym.spaces.Discrete(100), + ) + self.steps = 0 + self.closes = 0 + + def reset(self, *, seed=None, options=None): + """Return the current frame.""" + super().reset(seed=seed) + return {"image": self.render(), "count": self.steps}, {} + + def step(self, action): + """Advance the counter.""" + self.steps += 1 + obs, info = self.reset() + return obs, 1.0, False, False, info + + def render(self): + """Return RGB pixels.""" + return np.full((8, 9, 3), self.steps, dtype=np.uint8) + + def close(self): + """Record cleanup.""" + self.closes += 1 + + +def test_client(): + """Exercise registration, transitions, static resources, and native images.""" + + async def run(): + env = Pixels() + adapter = GymnasiumMCPAdapter(env) + assert adapter.name == "Gymnasium[unknown]" + async with fastmcp.Client(adapter.mcp) as client: + + async def read(uri): + result = await client.read_resource(uri) + return getattr(result, "contents", result)[0].text + + tools = await client.list_tools() + assert {t.name for t in getattr(tools, "tools", tools)} == { + "reset", + "step", + "render", + "close", + "sample_action", + } + prompts = await client.list_prompts() + assert getattr(prompts, "prompts", prompts)[0].name == "play_episode" + result = await client.call_tool("reset", {}) + payload = json.loads(result.content[0].text) + assert payload["total_reward"] == 0 + assert payload["observation"]["image"] == "Check image with `render` tool" + assert payload["observation"]["count"] == 0 + assert len(result.content) == 1 + rendered = await client.call_tool("render", {}) + assert rendered.content[0].type == "text" + block = rendered.content[1] + assert block.type == "image" and block.mimeType == "image/png" + pixels = np.asarray(Image.open(io.BytesIO(base64.b64decode(block.data)))) + np.testing.assert_array_equal(pixels, env.render()) + stepped = await client.call_tool("step", {"action": 0}) + stepped = await client.call_tool("step", {"action": 0}) + assert env.steps == 2 + assert json.loads(stepped.content[0].text)["total_reward"] == 2 + assert ( + json.loads(stepped.content[0].text)["observation"]["image"] + == "Check image with `render` tool" + ) + assert len(stepped.content) == 1 + invalid = await client.call_tool("step", {"action": 0.5}) + assert json.loads(invalid.content[0].text)["error"] + assert env.steps == 2 + reset = await client.call_tool("reset", {}) + assert json.loads(reset.content[0].text)["total_reward"] == 0 + assert (await client.call_tool("render", {})).content[1].type == "image" + first = await read("gymnasium://metadata") + env.metadata = {"changed": True} + assert await read("gymnasium://metadata") == first + resources = await client.list_resources() + for resource in getattr(resources, "resources", resources): + json.loads(await read(resource.uri)) + await client.call_tool("close", {}) + assert env.closes == 1 + + asyncio.run(run()) + + +def test_cartpole(): + """Run a standard environment without a renderer dependency.""" + adapter = GymnasiumMCPAdapter(gym.make("CartPole-v1")) + try: + assert adapter.name == "Gymnasium[CartPole-v1]" + adapter.reset(seed=0) + action = adapter.sample_action() + assert adapter.env.action_space.contains(action) + assert "reward" in json.loads(adapter.step(action)[0].text) + finally: + adapter.close() + + +@pytest.mark.parametrize("shape", [(8, 9), (8, 9, 1), (8, 9, 3), (8, 9, 4), (3, 8, 9)]) +def test_image_layouts(shape): + """Encode grayscale, RGB, RGBA, and channel-first images losslessly.""" + space = gym.spaces.Box(0, 255, shape, np.uint8) + value = space.sample() + assert encode(space, value, images=True) == "Check image with `render` tool" + img_layout = image_layout(space) + assert img_layout is not None + block = image_content(value, img_layout, 5_000_000) + decoded = np.asarray(Image.open(io.BytesIO(base64.b64decode(block.data)))) + expected = np.moveaxis(value, 0, -1) if shape == (3, 8, 9) else value.squeeze() + np.testing.assert_array_equal(decoded, expected) + with pytest.raises(ValueError, match="max_image_bytes"): + image_content(value, img_layout, 1) + assert encode(space, value) == value.tolist() + + +def test_non_image_and_bounds(): + """Keep tensors as JSON and represent infinite bounds explicitly.""" + space = gym.spaces.Box(-np.inf, np.inf, (2, 2), np.float32) + assert encode(space, np.zeros((2, 2), np.float32), images=True) == [[0, 0], [0, 0]] + assert describe(space)["low"] == [["-Infinity"] * 2] * 2 + + +def test_custom_space(): + """Use a custom space's codec and explain unsupported output.""" + + class Custom(gym.Space): + def contains(self, value): + return value == "valid" + + space = Custom() + assert encode(space, "valid") == "valid" + assert decode(space, "valid") == "valid" + with pytest.raises(ValueError, match="Custom"): + encode(space, object()) + + +@pytest.mark.parametrize("mode", ["rgb_array_list", "ansi", "human", None]) +def test_render_modes(mode, monkeypatch): + """Preserve frame order, text, and empty render status.""" + env = Pixels() + env.render_mode = mode + frames = [env.render(), env.render() + 1] + output = frames if mode == "rgb_array_list" else "scene" if mode == "ansi" else None + monkeypatch.setattr(env, "render", lambda: output) + adapter = GymnasiumMCPAdapter( + env, name="Custom", instructions="Custom instructions" + ) + assert adapter.name == "Custom" + assert "Custom instructions" in adapter.play_episode("win") + result = adapter.render() + if mode == "rgb_array_list": + for block, frame in zip(result[1:], frames): + np.testing.assert_array_equal( + np.asarray(Image.open(io.BytesIO(base64.b64decode(block.data)))), frame + ) + assert len(result) == 3 + elif mode == "ansi": + assert result.text == "scene" + else: + assert json.loads(result[1].text) == {"render_mode": mode, "data": None} + + +def test_cli(monkeypatch): + """Import registration modules before make and close on server failure.""" + calls = [] + env = Pixels() + monkeypatch.setattr(cli.importlib, "import_module", lambda name: calls.append(name)) + monkeypatch.setattr( + cli.gymnasium, "make", lambda name: (calls.append(name), env)[1] + ) + + class Server: + def __init__(self, value): + assert value is env + + def run(self, transport): + assert transport == "stdio" + raise RuntimeError("stopped") + + monkeypatch.setattr(cli, "GymnasiumMCPAdapter", Server) + with pytest.raises(RuntimeError, match="stopped"): + cli.main(["-i", " first, ,second ", "Test-v0"]) + assert calls == ["first", "second", "Test-v0"] + assert env.closes == 1 + + +@pytest.mark.parametrize("transport_flag", ["-t", "--transport"]) +@pytest.mark.parametrize("render_flag", ["-r", "--render-mode"]) +def test_cli_arguments(monkeypatch, transport_flag, render_flag): + """Forward typed environment arguments and select the MCP transport.""" + calls = {} + env = Pixels() + + def make(name, **kwargs): + calls.update(name=name, kwargs=kwargs) + return env + + class Server: + def __init__(self, value): + assert value is env + + def run(self, **kwargs): + calls["run"] = kwargs + + monkeypatch.setattr(cli.gymnasium, "make", make) + monkeypatch.setattr(cli, "GymnasiumMCPAdapter", Server) + cli.main( + [ + "--gravity", + "-9.8", + "Test-v0", + transport_flag, + "http", + render_flag, + "rgb_array", + "--max-episode-steps=12", + "--enabled", + "--disable-env-checker", + "true", + "--label", + "hello", + "--weights", + "[1, 2]", + "--optional", + "null", + "--kwargs", + '{"config": {"size": 3}}', + ] + ) + assert calls == { + "name": "Test-v0", + "kwargs": { + "gravity": -9.8, + "render_mode": "rgb_array", + "max_episode_steps": 12, + "enabled": True, + "disable_env_checker": True, + "label": "hello", + "weights": [1, 2], + "optional": None, + "config": {"size": 3}, + }, + "run": {"transport": "http"}, + } + assert env.closes == 1 + + +@pytest.mark.parametrize( + "arguments", + [ + ["--size", "3", "--kwargs", '{"size": 4}'], + ["--max-episode-steps=3", "--kwargs", '{"max_episode_steps": 4}'], + ["-r", "rgb_array", "--kwargs", '{"render_mode": "human"}'], + ["--size=3", "--size=4"], + ], +) +def test_cli_duplicate_kwargs(arguments): + """Reject collisions before constructing an environment.""" + with pytest.raises(KeyError, match="Duplicate environment argument"): + cli.main(["Test-v0", *arguments]) + + +@pytest.mark.parametrize("value", ["[]", "null", "1", "invalid"]) +def test_cli_invalid_kwargs(value): + """Require a JSON object for --kwargs.""" + with pytest.raises(SystemExit): + cli.main(["Test-v0", "--kwargs", value]) From 13e6509205a25b8ab65708bc393f73fd9e306020 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 12 Sep 2026 00:43:42 +0100 Subject: [PATCH 3/9] ci: add gymnasium mcp test to CI --- .github/workflows/run-tests.yml | 19 ++++++++++++++++++- bin/gymnasium-mcp.Dockerfile | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 bin/gymnasium-mcp.Dockerfile diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e066082..de0aaca 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -108,5 +108,22 @@ jobs: docker build -f bin/android_env.Dockerfile \ --build-arg PYTHON_VERSION='${{ matrix.python-version }}' \ --tag shimmy-android-env-docker . - - name: Run android_env tests + - name: Run AndroidEnv tests run: docker run shimmy-android-env-docker pytest tests/test_android_env.py + + optional-test-gymnasium-mcp: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.12'] + steps: + - uses: actions/checkout@v6 + + # AndroidEnv + - run: | + docker build -f bin/gymnasium-mcp.Dockerfile \ + --build-arg PYTHON_VERSION='${{ matrix.python-version }}' \ + --tag shimmy-gymnasium-mcp-docker . + - name: Run Gymnasium MCP tests + run: docker run shimmy-gymnasium-mcp-docker pytest tests/test_gymnasium_mcp.py diff --git a/bin/gymnasium-mcp.Dockerfile b/bin/gymnasium-mcp.Dockerfile new file mode 100644 index 0000000..a43e4ed --- /dev/null +++ b/bin/gymnasium-mcp.Dockerfile @@ -0,0 +1,16 @@ +# A Dockerfile that sets up an openspiel + +# if PYTHON_VERSION is not specified as a build argument, set it to 3.10. +ARG PYTHON_VERSION +ARG PYTHON_VERSION=${PYTHON_VERSION:-3.10} +FROM python:$PYTHON_VERSION + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN pip install --upgrade pip + +COPY . /usr/local/shimmy/ +WORKDIR /usr/local/shimmy/ + +# Install Shimmy +RUN pip install ".[mcp, testing]" --no-cache-dir From f8336c831c33b423b979dc4548062798187a6143 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 12 Sep 2026 00:47:22 +0100 Subject: [PATCH 4/9] style: fix pre-commit issues --- shimmy/mcp_adapters/gymnasium_interface.py | 19 +++++++++++++++---- shimmy/mcp_adapters/gymnasium_mcp.py | 12 ++++++++++-- shimmy/mcp_adapters/spaces.py | 10 ++++++---- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/shimmy/mcp_adapters/gymnasium_interface.py b/shimmy/mcp_adapters/gymnasium_interface.py index 54615fe..997414f 100644 --- a/shimmy/mcp_adapters/gymnasium_interface.py +++ b/shimmy/mcp_adapters/gymnasium_interface.py @@ -7,6 +7,7 @@ import gymnasium import numpy as np +from numpy.typing import NDArray from shimmy.mcp_adapters.spaces import ( decode, @@ -18,9 +19,11 @@ ) try: + from importlib import import_module + from fastmcp import FastMCP from mcp.types import TextContent - from importlib import import_module + import_module("PIL.Image") except ImportError as e: raise ImportError("MCP support requires: pip install 'shimmy[mcp]'") from e @@ -149,9 +152,13 @@ def render(self) -> Any: value = self.env.render() if isinstance(value, str): return TextContent(type="text", text=value) - is_list_expected = self.env.render_mode and self.env.render_mode.endswith("_list") + is_list_expected = self.env.render_mode and self.env.render_mode.endswith( + "_list" + ) frames = value if is_list_expected else [value] - assert frames is not None, f"render_mode of '{self.env.render_mode}' should not produce None" + assert ( + frames is not None + ), f"render_mode of '{self.env.render_mode}' should not produce None" if all(isinstance(elem, str) for elem in frames): return TextContent(type="text", text=str(frames)) content = [ @@ -167,7 +174,11 @@ def render(self) -> Any: ) if layout: content.append( - image_content(cast(np.typing.NDArray[np.uint8], frame), layout, self.max_image_bytes) + image_content( + cast(NDArray[np.uint8], frame), + layout, + self.max_image_bytes, + ) ) else: content.append( diff --git a/shimmy/mcp_adapters/gymnasium_mcp.py b/shimmy/mcp_adapters/gymnasium_mcp.py index 2e1def2..01daf74 100644 --- a/shimmy/mcp_adapters/gymnasium_mcp.py +++ b/shimmy/mcp_adapters/gymnasium_mcp.py @@ -21,6 +21,7 @@ class GymEnvironmentArgument(argparse.Action): """Process keyword arguments that should passthrough to ``gym.make``.""" def __call__(self, parser, namespace, values, option_string=None): + """Add arbitrary named arguments into ``namespace.environment_kwargs``.""" kwargs = getattr(namespace, "environment_kwargs", None) if kwargs is None: kwargs = namespace.environment_kwargs = {} @@ -43,7 +44,10 @@ def main(argv: list[str] | None = None) -> None: "-t", "--transport", default="stdio", help="MCP transport (default: stdio)" ) parser.add_argument( - "-r", "--render-mode", action=GymEnvironmentArgument, help="Gymnasium render mode" + "-r", + "--render-mode", + action=GymEnvironmentArgument, + help="Gymnasium render mode", ) parser.add_argument( "--kwargs", @@ -54,7 +58,11 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument("env_id") _, unknown = parser.parse_known_args(argv) for flag in dict.fromkeys(token.split("=", 1)[0] for token in unknown): - if flag.startswith("-") and flag != "--" and isinstance(try_load_json(flag), str): + if ( + flag.startswith("-") + and flag != "--" + and isinstance(try_load_json(flag), str) + ): parser.add_argument( flag, dest=flag.lstrip("-").replace("-", "_"), diff --git a/shimmy/mcp_adapters/spaces.py b/shimmy/mcp_adapters/spaces.py index db1e9ee..60eb127 100644 --- a/shimmy/mcp_adapters/spaces.py +++ b/shimmy/mcp_adapters/spaces.py @@ -4,11 +4,12 @@ import io import json from typing import Any, cast -from mcp.types import ImageContent -from PIL import Image + import gymnasium import numpy as np from gymnasium import spaces +from mcp.types import ImageContent +from PIL import Image def json_value(value: Any) -> Any: @@ -46,7 +47,6 @@ def image_layout(space: spaces.Space) -> str | None: def image_content(value: np.ndarray, layout: str, limit: int) -> Any: """Encode an image as native MCP content within the base64 byte limit.""" - if layout == "CHW": value = np.moveaxis(value, 0, -1) if value.ndim == 3 and value.shape[-1] == 1: @@ -197,7 +197,9 @@ def decode(space: spaces.Space, value: Any, path: str = "action") -> Any: result = result[()] else: result = space.from_jsonable([value])[0] - if not space.contains(result): # pyright: ignore[reportArgumentType,reportGeneralTypeIssues] + if not space.contains( + result # pyright: ignore[reportArgumentType,reportGeneralTypeIssues] + ): raise ValueError("value is outside the space") return result except ( From b01f69f37f6da426127441a8c350c0938edfb80e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 12 Sep 2026 00:49:01 +0100 Subject: [PATCH 5/9] style: address GitHub CQ issues --- tests/test_gymnasium_mcp.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_gymnasium_mcp.py b/tests/test_gymnasium_mcp.py index 87ebfb3..de9cad2 100644 --- a/tests/test_gymnasium_mcp.py +++ b/tests/test_gymnasium_mcp.py @@ -2,7 +2,6 @@ import asyncio import base64 -import builtins import io import json @@ -131,7 +130,7 @@ async def read(uri): assert block.type == "image" and block.mimeType == "image/png" pixels = np.asarray(Image.open(io.BytesIO(base64.b64decode(block.data)))) np.testing.assert_array_equal(pixels, env.render()) - stepped = await client.call_tool("step", {"action": 0}) + await client.call_tool("step", {"action": 0}) stepped = await client.call_tool("step", {"action": 0}) assert env.steps == 2 assert json.loads(stepped.content[0].text)["total_reward"] == 2 From 66a04ad259e718b12e4e0f9b0889f0d98c441a85 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 12 Sep 2026 01:03:18 +0100 Subject: [PATCH 6/9] fix: add compatibility to mcp==2.* --- shimmy/mcp_adapters/spaces.py | 10 +++++++++- tests/test_gymnasium_mcp.py | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/shimmy/mcp_adapters/spaces.py b/shimmy/mcp_adapters/spaces.py index 60eb127..d947109 100644 --- a/shimmy/mcp_adapters/spaces.py +++ b/shimmy/mcp_adapters/spaces.py @@ -3,6 +3,7 @@ import base64 import io import json +from importlib.metadata import version from typing import Any, cast import gymnasium @@ -11,6 +12,12 @@ from mcp.types import ImageContent from PIL import Image +MCP_MAJOR_VERSION = int(version("mcp").split(".")[0]) +if MCP_MAJOR_VERSION < 2: + MIMETYPE_FIELD = "mimeType" +else: + MIMETYPE_FIELD = "mime_type" + def json_value(value: Any) -> Any: """Convert NumPy values to strict JSON values.""" @@ -56,7 +63,8 @@ def image_content(value: np.ndarray, layout: str, limit: int) -> Any: data = base64.b64encode(buffer.getvalue()).decode("ascii") if len(data) > limit: raise ValueError(f"Encoded image exceeds max_image_bytes={limit}") - return ImageContent(type="image", data=data, mimeType="image/png") + kwargs: dict[str, str] = {MIMETYPE_FIELD: "image/png"} + return ImageContent(type="image", data=data, **kwargs) def encode(space: spaces.Space, value: Any, images: bool = False) -> Any: diff --git a/tests/test_gymnasium_mcp.py b/tests/test_gymnasium_mcp.py index de9cad2..5dd2e85 100644 --- a/tests/test_gymnasium_mcp.py +++ b/tests/test_gymnasium_mcp.py @@ -12,6 +12,7 @@ from shimmy.mcp_adapters import GymnasiumMCPAdapter from shimmy.mcp_adapters import gymnasium_mcp as cli from shimmy.mcp_adapters.spaces import ( + MIMETYPE_FIELD, decode, describe, encode, @@ -127,7 +128,9 @@ async def read(uri): rendered = await client.call_tool("render", {}) assert rendered.content[0].type == "text" block = rendered.content[1] - assert block.type == "image" and block.mimeType == "image/png" + assert ( + block.type == "image" and getattr(block, MIMETYPE_FIELD) == "image/png" + ) pixels = np.asarray(Image.open(io.BytesIO(base64.b64decode(block.data)))) np.testing.assert_array_equal(pixels, env.render()) await client.call_tool("step", {"action": 0}) From 06e519524bafeab5ecd4d667274c2b2c0effe121 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 12 Sep 2026 01:10:09 +0100 Subject: [PATCH 7/9] ci: update pre-commit ci dependencies --- .github/workflows/pre-commit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 716c67a..00cb01c 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 - - run: pip install pre-commit + - run: pip install pre-commit pillow "fastmcp>=2,<5" - run: pre-commit --version - run: pre-commit install - run: pre-commit run --all-files From 2f17649f7b73b82ac0ed6a721be6d091fce05cdd Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 12 Sep 2026 01:10:27 +0100 Subject: [PATCH 8/9] fix: allow graph space with no edge --- shimmy/mcp_adapters/spaces.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/shimmy/mcp_adapters/spaces.py b/shimmy/mcp_adapters/spaces.py index d947109..afe98f4 100644 --- a/shimmy/mcp_adapters/spaces.py +++ b/shimmy/mcp_adapters/spaces.py @@ -89,14 +89,14 @@ def encode(space: spaces.Space, value: Any, images: bool = False) -> Any: "value": encode(space.spaces[index], item, images), } if isinstance(space, spaces.Graph): - assert space.edge_space is not None + if value.edges is None: + edges = None + else: + assert space.edge_space is not None + edges = [encode(space.edge_space, v, images) for v in value.edges] return { "nodes": [encode(space.node_space, v, images) for v in value.nodes], - "edges": ( - None - if value.edges is None - else [encode(space.edge_space, v, images) for v in value.edges] - ), + "edges": edges, "edge_links": json_value(value.edge_links), } try: From 08bf5c5d8f6cacf1dff888e2aa727ef8ade59f68 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 12 Sep 2026 01:24:43 +0100 Subject: [PATCH 9/9] style: fix pyright complaints --- tests/test_gymnasium_mcp.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_gymnasium_mcp.py b/tests/test_gymnasium_mcp.py index 5dd2e85..796a5ff 100644 --- a/tests/test_gymnasium_mcp.py +++ b/tests/test_gymnasium_mcp.py @@ -4,6 +4,7 @@ import base64 import io import json +from typing import Any import gymnasium as gym import numpy as np @@ -63,7 +64,7 @@ def test_invalid_action(value): class Pixels(gym.Env): """Small deterministic environment for image and state tests.""" - metadata = {"render_modes": ["rgb_array", "rgb_array_list", "ansi"]} + metadata: dict[str, Any] = {"render_modes": ["rgb_array", "rgb_array_list", "ansi"]} render_mode = "rgb_array" def __init__(self):