""" Student MCP Server for Text Adventure Games This MCP server provides tools and resources for an AI agent to play text adventure games. Includes state tracking, mapping, and checkpoint utilities. Tools (actions with side effects): play_action(action: str) — Execute a game command get_valid_actions() — List valid actions (10s timeout) save_checkpoint(name: str) — Save game state load_checkpoint(name: str) — Restore game state Resources (free read-only context): game://inventory — Current inventory with object states game://state — Location, score, moves, failed actions game://history — Last 8 actions with ✓/✗/→ markers game://map — Explored locations, exits (tried/blocked) game://dictionary — Valid vocabulary words by POS game://unexplored_exits — Untried exits at current room Test your server with: fastmcp dev submission_template/mcp_server.py Then open the MCP Inspector in your browser to test the tools interactively. """ import sys import os import logging import signal from collections import deque from typing import Optional, Any from dataclasses import dataclass, field # Add parent directory to path to import games module sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from fastmcp import FastMCP from games.zork_env import TextAdventureEnv # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # ============================================================================= # Create the MCP Server # ============================================================================= mcp = FastMCP("Student Text Adventure Server") # ============================================================================= # Game State Management # ============================================================================= class GameManager: """ Manages the text adventure game state with comprehensive tracking. Features: - Action history tracking - Location exploration mapping - Score and progress monitoring - Checkpoint save/load functionality - Object discovery tracking """ # Direction aliases for movement tracking DIRECTION_ALIASES = { "n": "north", "s": "south", "e": "east", "w": "west", "u": "up", "d": "down", "ne": "northeast", "nw": "northwest", "se": "southeast", "sw": "southwest" } MOVEMENT_ACTIONS = { "north", "south", "east", "west", "up", "down", "northeast", "northwest", "southeast", "southwest", "enter", "exit", "climb", "go", "n", "s", "e", "w", "u", "d", "ne", "nw", "se", "sw" } # Directions to check in observations for smart exit detection DIRECTION_WORDS = { "north", "south", "east", "west", "up", "down", "northeast", "northwest", "southeast", "southwest", } def __init__(self): self.env: Optional[TextAdventureEnv] = None self.state: Optional[Any] = None self.game_name: str = "" # State tracking self.history: deque = deque(maxlen=100) # (action, observation, score_change) tuples self.explored_locations: dict[str, dict] = {} # location -> {exits, items, description} self.current_location: str = "" self.previous_location: str = "" self.discovered_items: set[str] = set() # All items ever seen self.visited_count: dict[str, int] = {} # How many times each location visited # Inventory tracking self.current_inventory: list[str] = [] # Currently held items self.inventory_history: list[tuple[int, str, str]] = [] # (move, action, item_change) # Exit tracking per room — for unexplored exit detection self.tried_exits: dict[str, dict[str, str]] = {} # location -> {direction -> result} self.failed_actions_per_loc: dict[str, list[str]] = {} # location -> [action1, ...] # Checkpoint system self.checkpoints: dict[str, tuple] = {} # name -> (env_state, game_manager_state) # Progress tracking self.max_score_achieved: int = 0 self.total_actions: int = 0 self.successful_actions: int = 0 # Actions that had visible effect self.last_error: Optional[str] = None # Track last error for debugging # Loop detection via state hashing self.state_history: list[str] = [] # List of state hashes self.state_history: list[str] = [] # List of state hashes self.loop_detected: bool = False self.action_history_hashes: set[tuple[int, str]] = set() # (state_hash, action) def initialize(self, game: str = "zork1") -> str: """Initialize or reset the game.""" self.game_name = game self.env = TextAdventureEnv(game) self.state = self.env.reset() # Reset state tracking self.history.clear() self.explored_locations.clear() self.current_location = self._extract_location(self.state.observation) self.previous_location = "" self.discovered_items.clear() self.visited_count.clear() self.tried_exits.clear() self.failed_actions_per_loc.clear() self.checkpoints.clear() self.max_score_achieved = 0 self.total_actions = 0 self.successful_actions = 0 # Reset loop detection self.state_history.clear() self.loop_detected = False self.state_history.clear() self.loop_detected = False self.action_history_hashes = set() # (state_hash, action) self._update_state_hash() # Initialize current location self._update_location_info(self.state.observation) return self.state.observation def _update_state_hash(self): """Update state hash history and check for loops.""" if self.env and hasattr(self.env.env, 'get_world_state_hash'): try: current_hash = self.env.env.get_world_state_hash() self.state_history.append(current_hash) # Check if this state has been seen frequently recently # If we've seen this exact state > 3 times in the last 20 moves, it's likely a loop recent_history = self.state_history[-20:] if recent_history.count(current_hash) > 3: self.loop_detected = True else: self.loop_detected = False except Exception: pass def _extract_location(self, observation: str) -> str: """Extract location name from observation. Uses Jericho's get_player_location() when available for reliable Z-machine-level tracking. Falls back to parsing observation text. """ # Try Jericho's built-in location tracking first if self.env and hasattr(self.env.env, 'get_player_location'): try: loc_obj = self.env.env.get_player_location() if loc_obj and hasattr(loc_obj, 'name') and loc_obj.name: return loc_obj.name except Exception: pass # Fallback: parse observation text # Only use the first line if it looks like a room name # (short, title-like, not a full sentence with verbs) lines = observation.strip().split('\n') for line in lines: clean = line.strip() if not clean or clean.startswith('>') or clean.startswith('['): continue # Room names are typically short and title-cased # Reject lines that are full sentences (contain common verbs) if len(clean) < 50 and not any(w in clean.lower() for w in ['look', 'not ', 'can\'t', 'is ', 'there', 'you ', 'that ', 'it ']): return clean return self.current_location or "Unknown" def _normalize_direction(self, action: str) -> str: """Normalize direction aliases to full names.""" action_lower = action.lower().strip() return self.DIRECTION_ALIASES.get(action_lower, action_lower) def _is_movement_action(self, action: str) -> bool: """Check if action is a movement command.""" action_lower = action.lower().strip() first_word = action_lower.split()[0] if action_lower.split() else "" return first_word in self.MOVEMENT_ACTIONS def _extract_items_from_observation(self, observation: str) -> list[str]: """Try to extract mentioned items from observation text.""" # Common item-related keywords item_keywords = ["take", "get", "lamp", "sword", "key", "bottle", "leaflet", "mailbox", "rope", "knife", "bag", "lantern", "egg", "jewel", "coin", "torch"] found_items = [] obs_lower = observation.lower() for keyword in item_keywords: if keyword in obs_lower: found_items.append(keyword) return found_items def _describe_object_state(self, obj, prev_attrs: list = None) -> str: """ Describe the state of a ZObject in a game-agnostic way. ZObject.attr is a 32-element numpy uint8 array (0/1 per bit). Since attribute numbering varies per game (Inform 6 vs 7 etc.), we DON'T try to map bit numbers to names. Instead, we: 1. Compare current attrs vs previous snapshot to detect changes 2. Use the object's name + child status for useful context """ labels = [] try: if hasattr(obj, 'attr'): current_attrs = [int(v) for v in obj.attr] # If we have a previous snapshot, detect changes if prev_attrs is not None: changed_bits = [] for i, (curr, prev) in enumerate(zip(current_attrs, prev_attrs)): if curr != prev: direction = "ON" if curr == 1 else "OFF" changed_bits.append(f"bit{i}→{direction}") if changed_bits: labels.append(f"state changed: {', '.join(changed_bits)}") else: labels.append("unchanged") # Check if the object has children (= container with things inside) if hasattr(obj, 'child') and obj.child > 0: labels.append("has contents") except Exception: pass return f" ({', '.join(labels)})" if labels else "" def _get_world_objects_map(self) -> dict: """Get all world objects indexed by ID for tree traversal.""" if not self.env or not hasattr(self.env.env, 'get_world_objects'): return {} try: return {o.num: o for o in self.env.env.get_world_objects()} except Exception: return {} def _get_children_recursive(self, parent_obj, obj_map, depth=0, max_depth=3) -> list[dict]: """Recursively find children of an object using the sibling chain.""" children = [] if depth > max_depth or not hasattr(parent_obj, 'child') or parent_obj.child <= 0: return children curr_id = parent_obj.child while curr_id > 0 and curr_id in obj_map: obj = obj_map[curr_id] # Get basic info name = obj.name if hasattr(obj, 'name') else str(obj) state = self._describe_object_state(obj) # Recurse grandkids = self._get_children_recursive(obj, obj_map, depth + 1, max_depth) children.append({ "name": name, "state": state, "contents": grandkids }) # Move to next sibling curr_id = obj.sibling if hasattr(obj, 'sibling') else 0 return children def get_inventory_with_state(self) -> list[dict]: """ Get inventory items using Jericho's ZObject API with state tracking. Compares current attributes against previous snapshots to detect state changes (e.g., torch lit → unlit). Returns list of dicts: [{"name": str, "state": str}] """ items = [] if not self.env or not self.env.env: return items # Initialize attribute snapshot storage if not hasattr(self, '_item_attr_snapshots'): self._item_attr_snapshots = {} try: inv_objects = self.env.env.get_inventory() for obj in inv_objects: name = obj.name if hasattr(obj, 'name') else str(obj) obj_num = obj.num if hasattr(obj, 'num') else id(obj) # Get previous snapshot for comparison prev_attrs = self._item_attr_snapshots.get(obj_num) state_desc = self._describe_object_state(obj, prev_attrs) # Save current attrs as snapshot for next comparison if hasattr(obj, 'attr'): self._item_attr_snapshots[obj_num] = [int(v) for v in obj.attr] items.append({ "name": name, "state": state_desc, }) except Exception: pass return items def get_inventory_recursive(self) -> list[dict]: """Get inventory with full hierarchy and state.""" if not self.env or not hasattr(self.env.env, 'get_inventory'): return [] try: # We need the full map to traverse children obj_map = self._get_world_objects_map() # Get top-level inventory items inv_roots = self.env.env.get_inventory() items = [] for obj in inv_roots: name = obj.name if hasattr(obj, 'name') else str(obj) state = self._describe_object_state(obj) contents = self._get_children_recursive(obj, obj_map) items.append({ "name": name, "state": state, "contents": contents }) return items except Exception: return [] def _extract_exits_from_observation(self, observation: str) -> set[str]: """Parse observation text for mentioned compass directions. Scans the room description for direction words to identify likely exits without blind brute-force. """ obs_lower = observation.lower() found = set() for d in self.DIRECTION_WORDS: if d in obs_lower: found.add(d) return found def _update_location_info(self, observation: str): """Update information about the current location.""" location = self.current_location if location not in self.explored_locations: self.explored_locations[location] = { "exits": set(), "items_seen": set(), "description": observation[:500], "mentioned_exits": set(), "first_visit": self.total_actions, "times_visited": 0 } # Update visit count self.explored_locations[location]["times_visited"] += 1 self.visited_count[location] = self.visited_count.get(location, 0) + 1 # Parse observation for mentioned exits mentioned = self._extract_exits_from_observation(observation) self.explored_locations[location]["mentioned_exits"] = mentioned # Try to find items items = self._extract_items_from_observation(observation) for item in items: self.discovered_items.add(item) self.explored_locations[location]["items_seen"].add(item) def _update_map_connection(self, action: str, new_location: str): """Record a connection between locations on the map.""" if self.previous_location and self.previous_location != new_location: direction = self._normalize_direction(action.split()[0]) if self.previous_location not in self.explored_locations: self.explored_locations[self.previous_location] = { "exits": set(), "items_seen": set(), "description": "", "first_visit": self.total_actions, "times_visited": 1 } self.explored_locations[self.previous_location]["exits"].add( f"{direction} -> {new_location}" ) def step(self, action: str) -> str: """Execute an action and return the result with enhanced tracking.""" if self.env is None: self.initialize() old_score = self.state.score if self.state else 0 old_observation = self.state.observation if self.state else "" old_location = self.current_location old_location = self.current_location # Loop Prevention: Strict State-Action Check # If we've done this exact action in this exact state before, strictly block it. # This forces the agent to try something else instead of looping. current_hash = 0 if self.env and hasattr(self.env.env, 'get_world_state_hash'): try: current_hash = self.env.env.get_world_state_hash() action_key = (current_hash, action.lower().strip()) if action_key in self.action_history_hashes: return f"❌ Loop prevented: You already tried '{action}' in this exact state. Try something different!" self.action_history_hashes.add(action_key) except Exception: pass self.state = self.env.step(action) self.total_actions += 1 score_change = self.state.score - old_score if self.state.score > self.max_score_achieved: self.max_score_achieved = self.state.score # Track if action had effect action_had_effect = old_observation != self.state.observation if action_had_effect: self.successful_actions += 1 # Update history with success/fail marker self.history.append((action, self.state.observation, score_change)) # Track movement exits and failed actions new_location = self._extract_location(self.state.observation) if self._is_movement_action(action): direction = self._normalize_direction(action.split()[0]) if new_location != self.current_location: # Successful movement self.previous_location = self.current_location self._update_map_connection(action, new_location) # Record this exit as successful if old_location not in self.tried_exits: self.tried_exits[old_location] = {} self.tried_exits[old_location][direction] = f"→ {new_location}" self.current_location = new_location self._update_location_info(self.state.observation) else: # Movement failed — record it if old_location not in self.tried_exits: self.tried_exits[old_location] = {} self.tried_exits[old_location][direction] = "BLOCKED" # Auto-block: if rejection lists valid exits, block all others # e.g. "only doorway to northwest and west" → block n,s,e,ne,se,sw,up,down mentioned_dirs = self._extract_exits_from_observation(self.state.observation) if mentioned_dirs: all_dirs = {"north","south","east","west","northeast","northwest", "southeast","southwest","up","down"} for d in all_dirs - mentioned_dirs: if d not in self.tried_exits[old_location]: self.tried_exits[old_location][d] = "BLOCKED" # Track non-movement failed actions if not action_had_effect or any(ind in self.state.observation.lower() for ind in ["can't", "not ", "already", "nothing", "impossible"]): loc = self.current_location if loc not in self.failed_actions_per_loc: self.failed_actions_per_loc[loc] = [] if action not in self.failed_actions_per_loc[loc]: self.failed_actions_per_loc[loc].append(action) self._update_state_hash() return self.state.observation def get_score(self) -> int: """Get current score.""" return self.state.score if self.state else 0 def get_moves(self) -> int: """Get number of moves taken.""" return self.state.moves if self.state else 0 def get_formatted_history(self, n: int = 10) -> str: """Get formatted recent history.""" if not self.history: return "No actions taken yet." recent = list(self.history)[-n:] lines = [] for i, (action, obs, score_delta) in enumerate(recent, 1): # Truncate observation for readability obs_short = obs[:100].replace('\n', ' ') if len(obs) > 100: obs_short += "..." score_str = f" (+{score_delta})" if score_delta > 0 else "" lines.append(f" {i}. > {action}{score_str}") lines.append(f" {obs_short}") return "\n".join(lines) def save_checkpoint(self, name: str) -> bool: """Save current game state to a named checkpoint.""" if self.env is None: return False try: env_state = self.env.save_state() manager_state = { "current_location": self.current_location, "previous_location": self.previous_location, "score": self.state.score, "moves": self.state.moves, "max_score_achieved": self.max_score_achieved } self.checkpoints[name] = (env_state, manager_state) return True except Exception: return False def load_checkpoint(self, name: str) -> bool: """Load a previously saved checkpoint.""" if name not in self.checkpoints: return False try: env_state, manager_state = self.checkpoints[name] self.env.load_state(env_state) # Restore state from saved checkpoint without executing a step # This avoids wasting a game move on "look" self.current_location = manager_state["current_location"] self.previous_location = manager_state["previous_location"] # Get current observation from environment without stepping if hasattr(self.env, 'get_state'): self.state = self.env.get_state() else: # Fallback: create minimal state from saved data from games.zork_env import GameState self.state = GameState( observation=f"Restored to {self.current_location}", score=manager_state.get("score", 0), moves=manager_state.get("moves", 0), done=False, info={} ) return True except Exception: return False # Global game manager _game = GameManager() def get_game() -> GameManager: """Get or initialize the game manager.""" global _game if _game.env is None: # Get game from environment variable (set by evaluator) game = os.environ.get("GAME", "zork1") _game.initialize(game) return _game # ============================================================================= # MCP Tools # ============================================================================= @mcp.tool() def play_action(action: str) -> str: """ Execute a game command and return the result. This is the main tool for interacting with the game. Args: action: The command to execute (e.g., "north", "take lamp", "open mailbox") Returns: The game's response to the action, plus score information Valid commands include: - Movement: north, south, east, west, up, down, enter, exit - Objects: take , drop , open , examine - Other: look, inventory, read , turn on lamp """ game = get_game() old_score = game.get_score() result = game.step(action) new_score = game.get_score() score_change = new_score - old_score # Build response with score info response = result if score_change > 0: response += f"\n\n🎉 +{score_change} points! (Total: {new_score})" elif score_change < 0: response += f"\n\n⚠️ {score_change} points. (Total: {new_score})" else: response += f"\n\n[Score: {new_score} | Moves: {game.get_moves()}]" if game.state.done: response += "\n\n🏁 GAME OVER" return response @mcp.tool() def save_checkpoint(name: str) -> str: """ Save the current game state to a named checkpoint. Args: name: A memorable name for this checkpoint (e.g., "before_troll", "got_lamp") Returns: Confirmation message indicating success or failure. Use this before risky actions or to mark progress! """ game = get_game() if game.save_checkpoint(name): return f"""💾 CHECKPOINT SAVED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Name: "{name}" Location: {game.current_location} Score: {game.get_score()} Moves: {game.get_moves()} Use load_checkpoint("{name}") to return here.""" else: return f"❌ Failed to save checkpoint '{name}'. Make sure the game is initialized." @mcp.tool() def load_checkpoint(name: str) -> str: """ Load a previously saved checkpoint. Args: name: The name of the checkpoint to load Returns: The game state after loading, or error message if checkpoint not found. """ game = get_game() if not game.checkpoints: return "❌ No checkpoints saved yet. Use save_checkpoint first!" if name not in game.checkpoints: available = ", ".join(game.checkpoints.keys()) return f"❌ Checkpoint '{name}' not found.\nAvailable checkpoints: {available}" if game.load_checkpoint(name): return f"""⏪ CHECKPOINT LOADED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Restored to: "{name}" Location: {game.current_location} Score: {game.get_score()} {game.state.observation if game.state else ''}""" else: return f"❌ Failed to load checkpoint '{name}'." # ============================================================================= # MCP Resources (read-only context the agent can query for free) # ============================================================================= @mcp.resource("game://dictionary") def get_dictionary_resource() -> str: """Game vocabulary categorized by part of speech (verbs, nouns, directions).""" game = get_game() if not game.env or not hasattr(game.env.env, 'get_dictionary'): return "Dictionary not available." try: vocab = game.env.env.get_dictionary() verbs, nouns, directions, other = [], [], [], [] for w in vocab: word = str(w) if hasattr(w, 'is_dir') and w.is_dir: directions.append(word) elif hasattr(w, 'is_verb') and w.is_verb: verbs.append(word) elif hasattr(w, 'is_noun') and w.is_noun: nouns.append(word) else: other.append(word) parts = [] if verbs: parts.append(f"Verbs ({len(verbs)}): {', '.join(sorted(verbs)[:40])}") if nouns: parts.append(f"Nouns ({len(nouns)}): {', '.join(sorted(nouns)[:40])}") if directions: parts.append(f"Directions ({len(directions)}): {', '.join(sorted(directions))}") return "\n".join(parts) if parts else f"Total words: {len(vocab)} (no POS tags available)" except Exception: return "Dictionary not available." @mcp.resource("game://inventory") def get_inventory_resource() -> str: """Current inventory with object states, hierarchy, and contents.""" game = get_game() items = game.get_inventory_recursive() def format_item(item, level=0): indent = " " * level marker = "📦 " if level == 0 else "↳ " lines = [f"{indent}{marker}{item['name']}{item['state']}"] for child in item['contents']: lines.extend(format_item(child, level + 1)) return lines if items: all_lines = [] for item in items: all_lines.extend(format_item(item)) return "\n".join(all_lines) return "Empty-handed." @mcp.resource("game://state") def get_game_state_resource() -> str: """Current game state: location, score, moves, exploration progress.""" game = get_game() # Try to get max score from Jericho max_possible = "?" if game.env and hasattr(game.env.env, 'get_max_score'): try: max_possible = game.env.env.get_max_score() except Exception: pass parts = [ f"Location: {game.current_location}", f"Score: {game.get_score()}/{max_possible} (best: {game.max_score_achieved})", f"Moves: {game.get_moves()}", f"Locations explored: {len(game.explored_locations)}", f"Actions: {game.successful_actions}/{game.total_actions} successful", ] if game.loop_detected: parts.append("⚠️ LOOP DETECTED — try a completely different action!") # Show failed actions at current location failed = game.failed_actions_per_loc.get(game.current_location, []) if failed: parts.append(f"❌ Failed here: {', '.join(failed[-8:])}") return "\n".join(parts) @mcp.resource("game://history") def get_history_resource() -> str: """Last 5 actions with compact result markers (✓ scored, → moved, ✗ failed).""" game = get_game() if not game.history: return "No actions taken yet." recent = list(game.history)[-5:] lines = [] for action, obs, score_delta in recent: # Compact markers if score_delta > 0: marker = f"✓ +{score_delta}" elif any(ind in obs.lower() for ind in ["can't", "not ", "already", "nothing"]): marker = "✗" else: marker = "→" obs_short = obs[:80].replace('\n', ' ') lines.append(f" {marker} {action}: {obs_short}") return "\n".join(lines) @mcp.resource("game://map") def get_map_resource() -> str: """Explored locations with exits (✓ = leads somewhere, ✗ = blocked).""" game = get_game() if not game.explored_locations: return "No locations explored yet." lines = [] for loc, info in sorted(game.explored_locations.items()): marker = "📍" if loc == game.current_location else " " lines.append(f"{marker} {loc} (visited {info.get('times_visited', 1)}x)") # Show tried exits with their results tried = game.tried_exits.get(loc, {}) for direction, result in sorted(tried.items()): lines.append(f" {'✓' if '→' in result else '✗'} {direction} {result}") # Show known exits from map connections (that might not be in tried_exits) for exit_info in sorted(info.get("exits", set())): lines.append(f" ↳ {exit_info}") items_seen = info.get("items_seen", set()) if items_seen: lines.append(f" 📦 Items: {', '.join(sorted(items_seen))}") return "\n".join(lines) @mcp.resource("game://unexplored_exits") def get_unexplored_exits_resource() -> str: """Smart exit info: mentioned exits from room description prioritized over blind guesses.""" game = get_game() loc = game.current_location if not loc: return "Location unknown." tried = game.tried_exits.get(loc, {}) loc_info = game.explored_locations.get(loc, {}) mentioned = loc_info.get("mentioned_exits", set()) # Categorize exits mentioned_untried = [d for d in sorted(mentioned) if d not in tried] other_untried = [d for d in ["up", "down", "northeast", "northwest", "southeast", "southwest"] if d not in tried and d not in mentioned] blocked = [f"{d}" for d, r in tried.items() if r == "BLOCKED"] succeeded = [f"{d} ({r})" for d, r in tried.items() if r != "BLOCKED"] parts = [] if mentioned_untried: parts.append(f"🚪 LIKELY EXITS (from room description): {', '.join(mentioned_untried)}") if succeeded: parts.append(f"✓ Already explored: {', '.join(succeeded)}") if blocked: parts.append(f"✗ Blocked: {', '.join(blocked)}") if other_untried: parts.append(f"? Other untried: {', '.join(other_untried)}") failed_actions = game.failed_actions_per_loc.get(loc, []) if failed_actions: parts.append(f"❌ Failed actions here: {', '.join(failed_actions[-6:])}") return "\n".join(parts) if parts else "No exit data yet." @mcp.resource("game://rooms") def get_rooms_resource() -> str: """Knowledge base of all visited rooms: description, exits, objects, and notes.""" game = get_game() if not game.explored_locations: return "No rooms explored yet." sections = [] for loc, info in sorted(game.explored_locations.items()): is_current = (loc == game.current_location) icon = "📍" if is_current else "Item" # Room Header part = [f"## {icon} {loc}"] part.append(f"_{info['description']}_") # Ground Truth Objects (from Z-Machine tree) if is_current: try: # Use Jericho to inspect the current room's objects if game.env and hasattr(game.env.env, 'get_player_location'): loc_obj = game.env.env.get_player_location() obj_map = game._get_world_objects_map() # Find everything in the room (children of room object) # Exclude the player themselves and common scenery noise if possible params = [] if loc_obj: room_contents = game._get_children_recursive(loc_obj, obj_map, max_depth=1) for item in room_contents: if "player" not in item['name'].lower() and "self" not in item['name'].lower(): desc = f"{item['name']}{item['state']}" if item['contents']: desc += f" (contains: {', '.join(c['name'] for c in item['contents'])})" params.append(desc) if params: part.append(f"\n🔎 GROUND TRUTH OBJECTS (Verified): {', '.join(params)}") except Exception: pass # Items Seen (Historical/Regex) items_seen = info.get("items_seen", set()) if items_seen: part.append(f"\n📜 Items seen in text: {', '.join(sorted(items_seen))}") # Exits part.append(f"\n🚪 Exits: {', '.join(sorted(info.get('exits', [])))}") sections.append("\n".join(part)) return "\n\n---\n\n".join(sections) @mcp.resource("game://actions") def get_actions_resource() -> str: """Smart action suggestions based on Game Grammar + Visible Objects.""" game = get_game() parts = [] # 1. Valid Verbs from Grammar (if available) verbs = [] try: if game.env and hasattr(game.env.env, 'bindings') and 'grammar' in game.env.env.bindings: # We filter useful templates to avoid spamming the LLM common = {'take', 'drop', 'open', 'close', 'examine', 'look', 'read', 'turn', 'push', 'pull', 'move', 'climb', 'enter', 'attack', 'kill', 'eat', 'drink', 'wear', 'remove', 'search', 'give', 'fill', 'light', 'throw', 'unlock'} grammar = game.env.env.bindings['grammar'] # Extract clean verb templates (e.g. "take [object]") templates = [] for g in grammar: if isinstance(g, bytes): g = g.decode('utf-8', errors='ignore') else: g = str(g) # Keep verbs starting with our common list if any(g.startswith(v) for v in common): # Replace internal Jericho tokens with readable placeholders clean_g = g.replace('OBJ', '').replace('CREATURE', '').replace('SOMETHING', '') if clean_g not in templates: templates.append(clean_g) if templates: parts.append(f"📚 KNOWN ACTION TEMPLATES:\n" + "\n".join(f"- {t}" for t in sorted(templates)[:25])) except Exception as e: parts.append("📚 COMMON VERBS: take , drop , open , examine , push , pull , turn , search, attack with ") # 2. Contextual Suggestions (Object + Verb) suggestions = [] # Get visible objects (Ground Truth) visible_names = [] try: if game.env and hasattr(game.env.env, 'get_player_location'): loc_obj = game.env.env.get_player_location() obj_map = game._get_world_objects_map() raw_contents = game._get_children_recursive(loc_obj, obj_map, max_depth=0) visible_names = [i['name'] for i in raw_contents if "player" not in i['name'].lower()] except Exception: pass # Get inventory names inv_names = [i['name'] for i in game.get_inventory_recursive()] if visible_names: parts.append(f"\n👀 VISIBLE OBJECTS: {', '.join(visible_names)}") # Generate interaction pairs for obj in visible_names[:5]: # limit to first few to avoid spam suggestions.append(f"examine {obj}") suggestions.append(f"take {obj}") if "door" in obj or "chest" in obj or "box" in obj: suggestions.append(f"open {obj}") if inv_names: parts.append(f"\n🎒 INVENTORY: {', '.join(inv_names)}") for obj in inv_names[:3]: suggestions.append(f"examine {obj}") suggestions.append(f"drop {obj}") if suggestions: parts.append(f"\n💡 SUGGESTED ACTIONS:\n- " + "\n- ".join(suggestions[:8])) return "\n".join(parts) # ============================================================================= # Run the server # ============================================================================= @mcp.resource("game://valid_directions") def get_valid_directions_resource() -> str: """Return a comma-separated list of known valid directions for the current location.""" try: game = get_game() loc = game.current_location if not loc or loc not in game.explored_locations: return "" info = game.explored_locations[loc] # Combine mentioned exits and successful tried exits mentioned = info.get("mentioned_exits", set()) tried = game.tried_exits.get(loc, {}) valid = set() # Add mentioned if mentioned: valid.update(mentioned) # Add tried and verify if blocked for d, res in tried.items(): if "->" in res: valid.add(d) elif "blocked" in res.lower() or "can't" in res.lower(): if d in valid: valid.discard(d) return ", ".join(sorted(valid)) except Exception: return "" if __name__ == "__main__": # This runs the server with stdio transport (for MCP clients) mcp.run()