text-adventure-template / mcp_server.py
Clarelec
feat: Enhance agent strategy with a detailed system prompt, new state tracking, and update the README to a French implementation report.
e97ef73
"""
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 <item>, drop <item>, open <thing>, examine <thing>
- Other: look, inventory, read <thing>, 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', '<object>').replace('CREATURE', '<creature>').replace('SOMETHING', '<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 <obj>, drop <obj>, open <obj>, examine <obj>, push <obj>, pull <obj>, turn <obj>, search, attack <creature> with <weapon>")
# 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()