Spaces:
Sleeping
Sleeping
| """ | |
| MCP Server for Text Adventure Games | |
| Exposes a text adventure environment (via Jericho) as a set of MCP tools. | |
| Tools available to the agent: | |
| - execute : run a game command and get the response | |
| - snapshot : get structured JSON game state (observation, score, inventory, valid commands…) | |
| - session_log : human-readable summary of recent history | |
| - world_map : explored locations and their connections | |
| - carried_items : list of items currently in the player's possession | |
| """ | |
| import sys | |
| import os | |
| import json | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| from fastmcp import FastMCP | |
| from games.zork_env import TextAdventureEnv | |
| INITIAL_GAME = os.environ.get("GAME", "zork1") | |
| mcp = FastMCP("Text Adventure MCP Server") | |
| # ============================================================================= | |
| # GameState — wraps the Jericho environment and all derived state | |
| # ============================================================================= | |
| class GameState: | |
| """ | |
| Wraps a single Jericho game session. | |
| Tracks the raw environment state, a rolling command history, and a | |
| lightweight world graph (room -> set of "direction -> room" strings) | |
| built from every successful navigation command. | |
| """ | |
| def __init__(self, game: str = "zork1") -> None: | |
| self.game_name = game | |
| self.env = TextAdventureEnv(game) | |
| self.env_state = self.env.reset() | |
| self.cmd_history: list[tuple[str, str]] = [] # (command, response) | |
| self.world_graph: dict[str, set[str]] = {} # room -> {"dir -> room", ...} | |
| self.current_room: str = self._parse_room_header(self.env_state.observation) | |
| # ------------------------------------------------------------------ | |
| # Internal helpers | |
| # ------------------------------------------------------------------ | |
| def _parse_room_header(self, observation: str) -> str: | |
| """Return the first meaningful line of an observation as the room name.""" | |
| for line in observation.strip().splitlines(): | |
| stripped = line.strip() | |
| if stripped and not stripped.startswith("[") and not stripped.startswith("+"): | |
| return stripped | |
| return "Unknown" | |
| _NAV_COMMANDS = frozenset([ | |
| "north", "south", "east", "west", "up", "down", | |
| "enter", "exit", "n", "s", "e", "w", "u", "d", | |
| ]) | |
| def _is_navigation(self, command: str) -> bool: | |
| return command.lower().strip() in self._NAV_COMMANDS | |
| # ------------------------------------------------------------------ | |
| # Core action execution | |
| # ------------------------------------------------------------------ | |
| def execute_command(self, command: str) -> str: | |
| """Step the environment with *command* and update derived state.""" | |
| self.env_state = self.env.step(command) | |
| response = self.env_state.observation | |
| # Rolling history (capped at 60 entries) | |
| self.cmd_history.append((command, response)) | |
| if len(self.cmd_history) > 60: | |
| self.cmd_history = self.cmd_history[-60:] | |
| # Update world graph on successful navigation | |
| new_room = self.env_state.location or self._parse_room_header(response) | |
| if self._is_navigation(command) and new_room != self.current_room: | |
| self.world_graph.setdefault(self.current_room, set()).add( | |
| f"{command.lower().strip()} -> {new_room}" | |
| ) | |
| self.current_room = new_room | |
| return response | |
| # ------------------------------------------------------------------ | |
| # Query methods (read-only) | |
| # ------------------------------------------------------------------ | |
| def query_valid_commands(self) -> list[str]: | |
| """Ask Jericho for the list of currently valid commands.""" | |
| try: | |
| return self.env.get_valid_actions() | |
| except Exception: | |
| return [] | |
| def snapshot(self) -> dict: | |
| """Return a structured dict with everything the agent needs.""" | |
| return { | |
| "observation": self.env_state.observation, | |
| "location": self.env_state.location or self.current_room, | |
| "score": self.env_state.score, | |
| "max_score": self.env_state.max_score, | |
| "moves": self.env_state.moves, | |
| "done": self.env_state.done, | |
| "reward": self.env_state.reward, | |
| "inventory": self.env_state.inventory or [], | |
| "valid_commands": self.query_valid_commands(), | |
| } | |
| def session_log(self) -> str: | |
| """Human-readable summary of the current session.""" | |
| recent = self.cmd_history[-5:] | |
| recent_lines = ( | |
| "\n".join(f" > {cmd} -> {resp[:60]}..." for cmd, resp in recent) | |
| if recent else " (no commands yet)" | |
| ) | |
| s = self.env_state | |
| return ( | |
| f"Room : {self.current_room}\n" | |
| f"Score : {s.score} / {s.max_score}\n" | |
| f"Moves : {s.moves}\n" | |
| f"Game : {self.game_name}\n" | |
| f"\nRecent commands:\n{recent_lines}\n" | |
| f"\nCurrent observation:\n{s.observation}" | |
| ) | |
| def render_world_map(self) -> str: | |
| """Render the explored world graph as indented text.""" | |
| if not self.world_graph: | |
| return "World map: no locations explored yet — move around to build the map." | |
| lines = ["Explored world:"] | |
| for room in sorted(self.world_graph): | |
| lines.append(f"\n [{room}]") | |
| for connection in sorted(self.world_graph[room]): | |
| lines.append(f" {connection}") | |
| lines.append(f"\n [You are here] {self.current_room}") | |
| return "\n".join(lines) | |
| def list_carried_items(self) -> str: | |
| """Return a readable inventory string.""" | |
| raw_items = ( | |
| self.env_state.inventory | |
| if hasattr(self.env_state, "inventory") and self.env_state.inventory | |
| else [] | |
| ) | |
| if not raw_items: | |
| return "Carrying: nothing." | |
| names: list[str] = [] | |
| for item in raw_items: | |
| item_str = str(item) | |
| lower = item_str.lower() | |
| if "parent" in lower: | |
| chunk = item_str[: lower.index("parent")].strip() | |
| names.append(chunk.split(":", 1)[1].strip() if ":" in chunk else chunk) | |
| elif ":" in item_str: | |
| names.append(item_str.split(":", 1)[1].strip()) | |
| else: | |
| names.append(item_str) | |
| return "Carrying: " + ", ".join(names) | |
| # ============================================================================= | |
| # Singleton session | |
| # ============================================================================= | |
| _session: GameState | None = None | |
| def get_session() -> GameState: | |
| """Return the singleton GameState, initialising it on first call.""" | |
| global _session | |
| if _session is None: | |
| _session = GameState(INITIAL_GAME) | |
| return _session | |
| # ============================================================================= | |
| # MCP Tools | |
| # ============================================================================= | |
| def execute(command: str) -> str: | |
| """ | |
| Run a game command and return the game's response. | |
| Args: | |
| command: A valid game command, e.g. 'north', 'take lamp', 'open mailbox'. | |
| Returns: | |
| The game's text response, followed by score / move count. | |
| Appends 'GAME OVER' when the session ends. | |
| """ | |
| sess = get_session() | |
| response = sess.execute_command(command) | |
| s = sess.env_state | |
| score_line = ( | |
| f"\n\n+{s.reward} pts (total: {s.score})" | |
| if s.reward > 0 | |
| else f"\n\n[Score: {s.score} | Moves: {s.moves}]" | |
| ) | |
| end_line = "\n\nGAME OVER" if s.done else "" | |
| return response + score_line + end_line | |
| def snapshot() -> str: | |
| """ | |
| Return the full structured game state as a JSON string. | |
| Fields: observation, location, score, max_score, moves, done, | |
| reward, inventory, valid_commands. | |
| Prefer this over 'session_log' when you need machine-readable data. | |
| """ | |
| return json.dumps(get_session().snapshot()) | |
| def session_log() -> str: | |
| """ | |
| Return a human-readable summary of the current session. | |
| Includes current room, score, move count, recent command history, | |
| and the current observation. | |
| """ | |
| return get_session().session_log() | |
| def world_map() -> str: | |
| """ | |
| Return a text rendering of all explored rooms and their connections. | |
| Use this to plan routes and avoid revisiting dead ends. | |
| """ | |
| return get_session().render_world_map() | |
| def carried_items() -> str: | |
| """ | |
| List the items you are currently carrying. | |
| """ | |
| return get_session().list_carried_items() | |
| # ============================================================================= | |
| # Entry point | |
| # ============================================================================= | |
| if __name__ == "__main__": | |
| mcp.run() | |