Skip to main content
Glama
vibe_tide_mcp_server.py43 kB
#!/usr/bin/env python """ VibeTide MCP Server A Model Context Protocol server for interacting with VibeTide levels. Provides tools for: - Viewing level data and visualizations - Playing levels in the web player - Editing levels (entire level, single row, or single spot) - Creating new levels with AI - Decoding levels from URLs Compatible with any MCP client. """ import base64 import io import json import logging import os import tempfile from typing import Any, Dict, List, Optional import uvicorn from mcp.server.fastmcp import FastMCP from starlette.middleware.cors import CORSMiddleware from PIL import Image, ImageDraw, ImageFont # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Initialize FastMCP server mcp = FastMCP("vibe-tide-server") # VibeTide configuration - only web player needed VIBE_TIDE_CONFIG = { "web_player_url": "https://vibetideplayer.banjtheman.xyz/", } # Tile type mappings TILE_TYPES = { 0: {"name": "Empty", "symbol": "⬜", "description": "Walkable air space"}, 1: {"name": "Grass", "symbol": "🌱", "description": "Standard ground platform"}, 2: {"name": "Rock", "symbol": "🗿", "description": "Solid stone platform"}, 3: {"name": "Yellow", "symbol": "⭐", "description": "Special yellow platform"}, 4: {"name": "Ice", "symbol": "❄️", "description": "Slippery ice platform"}, 5: {"name": "Red", "symbol": "🔥", "description": "Dangerous red platform"}, 6: {"name": "Spikes", "symbol": "⚠️", "description": "Hazardous spikes"}, 7: {"name": "Water", "symbol": "💧", "description": "Water tiles"}, } # Tile colors matching the web builder TILE_COLORS = { 0: (249, 250, 251), # Empty - light gray 1: (74, 222, 128), # Grass - green 2: (107, 114, 128), # Rock - gray 3: (250, 204, 21), # Yellow - yellow 4: (56, 189, 248), # Ice - light blue 5: (239, 68, 68), # Fire/Red - red 6: (139, 92, 246), # Spikes - purple 7: (6, 182, 212), # Water - cyan } # Border colors for tiles TILE_BORDER_COLORS = { 0: (229, 231, 235), # Empty tiles get a light border 1: (34, 197, 94), # Slightly darker versions for borders 2: (75, 85, 99), 3: (217, 171, 3), 4: (2, 132, 199), 5: (220, 38, 38), 6: (124, 58, 237), 7: (8, 145, 178), } # Tile labels for small tiles TILE_LABELS = {0: "", 1: "G", 2: "R", 3: "Y", 4: "I", 5: "F", 6: "S", 7: "W"} class LevelEncoder: """Fast level encoding/decoding without agent tools""" def __init__(self): # Character mapping for tiles (URL-safe) self.tile_chars = { 0: ".", # Empty - most common 1: "G", # Grass 2: "R", # Rock 3: "Y", # Yellow 4: "I", # Ice 5: "F", # Fire/Red 6: "S", # Spikes 7: "W", # Water } self.char_tiles = {char: tile for tile, char in self.tile_chars.items()} def encode(self, level_data: Dict[str, Any]) -> str: """Encode level data to URL-safe string""" try: tiles = level_data.get("tiles", []) if not tiles: raise ValueError("No tiles found") height = len(tiles) width = len(tiles[0]) if tiles else 0 # Convert 2D array to character string tile_string = "" for row in tiles: for tile in row: tile_string += self.tile_chars.get(tile, ".") # Apply run-length encoding for empty tiles tile_string = self._run_length_encode(tile_string) # Create base format encoded = f"{width}x{height}:{tile_string}" # Add game parameters if provided params = {} for param in ["maxEnemies", "enemySpawnChance", "coinSpawnChance"]: if param in level_data and level_data[param] is not None: params[param] = level_data[param] if params: params_json = json.dumps(params) encoded += f"|{self._base64url_encode(params_json)}" return self._base64url_encode(encoded) except Exception as e: logger.error(f"Failed to encode level: {e}") raise ValueError(f"Level encoding failed: {str(e)}") def decode(self, encoded_string: str) -> Dict[str, Any]: """Decode URL-safe string back to level data""" try: encoded_string = encoded_string.strip() if not encoded_string: raise ValueError("Empty encoded string") # Decode from base64url decoded = self._base64url_decode(encoded_string) # Parse parameters if present main_data = decoded game_params = {} if "|" in decoded: parts = decoded.split("|", 1) if len(parts) == 2: main_data, params_data = parts try: params_json = self._base64url_decode(params_data) game_params = json.loads(params_json) except Exception as e: logger.warning(f"Failed to parse parameters: {e}") # Parse format: widthxheight:encoded_data if ":" not in main_data: raise ValueError("Invalid format: missing colon") dimensions, tile_data = main_data.split(":", 1) if "x" not in dimensions: raise ValueError("Invalid format: missing dimensions") width, height = map(int, dimensions.split("x")) if width < 1 or height < 1: raise ValueError(f"Invalid dimensions: {width}x{height}") # Decode run-length encoding tile_string = self._run_length_decode(tile_data) # Convert back to 2D array tiles = [] index = 0 for y in range(height): tiles.append([]) for x in range(width): char = tile_string[index] if index < len(tile_string) else "." tiles[y].append(self.char_tiles.get(char, 0)) index += 1 result = { "tiles": tiles, "width": width, "height": height, "name": self._generate_level_name(tiles), } # Add game parameters for param in ["maxEnemies", "enemySpawnChance", "coinSpawnChance"]: if param in game_params: result[param] = game_params[param] return result except Exception as e: logger.error(f"Failed to decode level: {e}") raise ValueError(f"Level decoding failed: {str(e)}") def _run_length_encode(self, s: str) -> str: """Run-length encoding for repeated empty tiles""" if not s: return s result = "" count = 1 current = s[0] for i in range(1, len(s)): if s[i] == current and current == ".": count += 1 else: if current == "." and count > 2: result += f".{count}" else: result += current * count current = s[i] count = 1 # Handle final sequence if current == "." and count > 2: result += f".{count}" else: result += current * count return result def _run_length_decode(self, s: str) -> str: """Decode run-length encoding""" result = "" i = 0 while i < len(s): char = s[i] if char == "." and i + 1 < len(s) and s[i + 1].isdigit(): # Extract number num_str = "" j = i + 1 while j < len(s) and s[j].isdigit(): num_str += s[j] j += 1 count = int(num_str) result += "." * count i = j else: result += char i += 1 return result def _base64url_encode(self, s: str) -> str: """URL-safe base64 encoding""" encoded = base64.b64encode(s.encode("utf-8")).decode("ascii") return encoded.replace("+", "-").replace("/", "_").rstrip("=") def _base64url_decode(self, s: str) -> str: """URL-safe base64 decoding""" try: # Add padding if necessary padding = (4 - len(s) % 4) % 4 s += "=" * padding s = s.replace("-", "+").replace("_", "/") decoded_bytes = base64.b64decode(s) return decoded_bytes.decode("utf-8") except Exception as e: logger.error(f"Base64 decode error: {e}") raise ValueError(f"Invalid base64 encoding: {str(e)}") def _generate_level_name(self, tiles: List[List[int]]) -> str: """Generate a name based on level content""" stats = self._analyze_level_content(tiles) name = "" if stats["water"] > 0.3: name += "Aquatic " if stats["spikes"] > 0.1: name += "Dangerous " if stats["ice"] > 0.2: name += "Icy " if stats["platforms"] < 0.1: name += "Minimal " if stats["platforms"] > 0.4: name += "Dense " name += "Adventure" return name.strip() def _analyze_level_content(self, tiles: List[List[int]]) -> Dict[str, float]: """Analyze level content for statistics""" if not tiles: return { "empty": 1.0, "platforms": 0.0, "ice": 0.0, "dangerous": 0.0, "spikes": 0.0, "water": 0.0, } total = len(tiles) * len(tiles[0]) counts = {i: 0 for i in range(8)} for row in tiles: for tile in row: counts[tile] = counts.get(tile, 0) + 1 return { "empty": counts[0] / total, "platforms": (counts[1] + counts[2] + counts[3]) / total, "ice": counts[4] / total, "dangerous": counts[5] / total, "spikes": counts[6] / total, "water": counts[7] / total, } # Global encoder instance level_encoder = LevelEncoder() def extract_json_from_response(response_text: str) -> Dict[str, Any]: """Extract JSON from agent response""" try: # Ensure we have a string if not isinstance(response_text, str): response_text = str(response_text) response_text = response_text.strip() # Handle markdown code blocks - strip ``` if present if response_text.startswith("```"): # Find the JSON content between code blocks lines = response_text.split("\n") json_lines = [] in_json = False for line in lines: if line.strip().startswith("```"): if not in_json: in_json = True else: break # End of code block elif in_json: json_lines.append(line) response_text = "\n".join(json_lines).strip() # Try to parse entire response as JSON try: return json.loads(response_text) except json.JSONDecodeError as e: logger.warning(f"Initial JSON parsing failed: {e}") logger.warning(f"Response preview: {response_text[:500]}...") # Try to extract JSON from text using regex import re # Look for JSON objects that contain "tiles" json_matches = re.findall( r'\{[^{}]*"tiles"[^{}]*\}', response_text, re.DOTALL ) if json_matches: logger.info("Found JSON match with regex") return json.loads(json_matches[0]) # Try to find any JSON-like structure json_matches = re.findall(r"\{.*?\}", response_text, re.DOTALL) if json_matches: # Try each match for match in json_matches: try: parsed = json.loads(match) if "tiles" in parsed: logger.info("Found valid JSON with tiles in regex match") return parsed except json.JSONDecodeError: continue # If all else fails, try to reconstruct from visible structure # Look for patterns like "tiles": [[ in the response if '"tiles"' in response_text and "[[" in response_text: logger.warning("Attempting to reconstruct JSON from partial response") # This is a fallback - try to wait for complete response raise ValueError( f"JSON appears incomplete - response might be streaming: {response_text[:200]}..." ) raise ValueError( f"No valid JSON found in response. Response: {response_text[:200]}..." ) except Exception as e: logger.error(f"JSON extraction failed: {e}") logger.error(f"Response text: {response_text[:500]}...") raise ValueError(f"Failed to extract JSON from response: {str(e)}") def is_encoded_level(data) -> bool: """Check if data is an encoded level string""" if isinstance(data, str) and len(data) > 10: try: # Try to decode it level_encoder.decode(data) return True except: return False return False def prepare_level_for_editing(current_level) -> Dict[str, Any]: """Prepare level data for editing agent""" if current_level is None: return None # If it's an encoded string, decode it if is_encoded_level(current_level): return level_encoder.decode(current_level) # If it's a dict with encoded level, decode that if isinstance(current_level, dict) and "encodedLevel" in current_level: return level_encoder.decode(current_level["encodedLevel"]) # If it's already level data with tiles, return as-is if isinstance(current_level, dict) and "tiles" in current_level: return current_level return None def visualize_level(level_data: Dict[str, Any]) -> str: """Create a visual representation of the level using symbols""" tiles = level_data.get("tiles", []) if not tiles: return "Empty level" visualization = [] visualization.append(f"Level: {level_data.get('name', 'Unnamed')}") visualization.append(f"Size: {len(tiles[0])}x{len(tiles)}") # Add game parameters if present params = [] if "maxEnemies" in level_data: params.append(f"Max Enemies: {level_data['maxEnemies']}") if "enemySpawnChance" in level_data: params.append(f"Enemy Spawn: {level_data['enemySpawnChance']}%") if "coinSpawnChance" in level_data: params.append(f"Coin Spawn: {level_data['coinSpawnChance']}%") if params: visualization.append("Parameters: " + ", ".join(params)) visualization.append("\nLevel Layout:") visualization.append("=" * (len(tiles[0]) + 2)) for row in tiles: row_str = "|" for tile in row: symbol = TILE_TYPES.get(tile, {"symbol": "?"})["symbol"] row_str += symbol row_str += "|" visualization.append(row_str) visualization.append("=" * (len(tiles[0]) + 2)) # Add legend visualization.append("\nTile Legend:") for tile_id, tile_info in TILE_TYPES.items(): visualization.append( f" {tile_info['symbol']} = {tile_info['name']} ({tile_info['description']})" ) return "\n".join(visualization) def generate_level_image_to_file( level_data: Dict[str, Any], tile_size: int = 16, max_width: int = 1200 ) -> str: """Generate a PNG image of the level and save to a temporary file. Returns the file path.""" tiles = level_data.get("tiles", []) # Create a temporary file temp_file = tempfile.NamedTemporaryFile( delete=False, suffix=".png", prefix="vibe_tide_level_" ) temp_path = temp_file.name temp_file.close() if not tiles: # Create a small error image img = Image.new("RGB", (200, 100), (255, 255, 255)) draw = ImageDraw.Draw(img) draw.text((10, 40), "Empty Level", fill=(0, 0, 0)) img.save(temp_path, format="PNG") return temp_path height = len(tiles) width = len(tiles[0]) if tiles else 0 # Calculate image dimensions # For very wide levels, make tiles smaller to fit in max_width actual_tile_size = min(tile_size, max_width // width) if width > 0 else tile_size actual_tile_size = max(4, actual_tile_size) # Minimum 4px per tile img_width = width * actual_tile_size img_height = height * actual_tile_size # Create image with white background img = Image.new("RGB", (img_width, img_height), (255, 255, 255)) draw = ImageDraw.Draw(img) # Try to load a font for labels (fallback to default if not available) try: font = ImageFont.truetype("arial.ttf", max(8, actual_tile_size // 2)) except: try: font = ImageFont.load_default() except: font = None # Draw tiles for y in range(height): for x in range(width): tile_type = tiles[y][x] # Calculate tile position left = x * actual_tile_size top = y * actual_tile_size right = left + actual_tile_size bottom = top + actual_tile_size # Get colors fill_color = TILE_COLORS.get(tile_type, (128, 128, 128)) border_color = TILE_BORDER_COLORS.get(tile_type, (0, 0, 0)) # Draw tile background draw.rectangle( [left, top, right - 1, bottom - 1], fill=fill_color, outline=border_color, ) # Add label for non-empty tiles if tile is large enough if actual_tile_size >= 12 and tile_type != 0: label = TILE_LABELS.get(tile_type, str(tile_type)) if label and font: # Calculate text position (center of tile) bbox = draw.textbbox((0, 0), label, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] text_x = left + (actual_tile_size - text_width) // 2 text_y = top + (actual_tile_size - text_height) // 2 # Use white text for dark backgrounds, black for light text_color = (255, 255, 255) if sum(fill_color) < 400 else (0, 0, 0) draw.text((text_x, text_y), label, fill=text_color, font=font) # Save to temporary file img.save(temp_path, format="PNG", optimize=True) return temp_path @mcp.tool() async def view_level(encoded_level: str) -> Dict[str, Any]: """View a VibeTide level with visual representation. Args: encoded_level: An encoded level string from a URL or sharing link Returns a visual representation of the level. """ try: # Decode level from string level_data = level_encoder.decode(encoded_level) play_url = f"{VIBE_TIDE_CONFIG['web_player_url']}?level={encoded_level}" # Generate visualization visualization = visualize_level(level_data) return { "success": True, "level_data": level_data, "visualization": visualization, "play_url": play_url, "message": "Level visualized successfully", } except Exception as e: logger.error(f"Failed to view level: {e}") return {"success": False, "error": f"Failed to view level: {str(e)}"} @mcp.tool() async def view_level_image( encoded_level: str, tile_size: int = 16, max_width: int = 1200 ) -> str: """View a VibeTide level as a beautiful PNG image with proper colors. This generates a much better visual representation than the ASCII version, using the same colors as the web builder. The image is saved to a temporary file and the file path is returned for MCP clients to display. Args: encoded_level: An encoded level string from a URL or sharing link tile_size: Size of each tile in pixels (default 16, will auto-adjust for wide levels) max_width: Maximum image width in pixels (default 1200) Returns: The file path to the generated PNG image """ try: # Decode level from string level_data = level_encoder.decode(encoded_level) # Generate PNG image to temporary file image_path = generate_level_image_to_file(level_data, tile_size, max_width) return image_path except Exception as e: logger.error(f"Failed to generate level image: {e}") # Return error as string since we're returning a simple string now raise Exception(f"Failed to generate level image: {str(e)}") @mcp.tool() async def edit_level_metadata( encoded_level: str, new_name: Optional[str] = None, new_description: Optional[str] = None, max_enemies: Optional[int] = None, enemy_spawn_chance: Optional[float] = None, coin_spawn_chance: Optional[float] = None, ) -> Dict[str, Any]: """Edit only the metadata of a VibeTide level without changing the tile layout. This is much more efficient than edit_entire_level when you only want to change game parameters like enemy count, spawn rates, name, or description. Args: encoded_level: The encoded level string to modify new_name: New name for the level (optional) new_description: New description for the level (optional) max_enemies: Maximum enemies parameter (0-10, optional) enemy_spawn_chance: Enemy spawn chance percentage (0-100, optional) coin_spawn_chance: Coin spawn chance percentage (0-100, optional) Returns: The modified level data with new encoded string """ try: # Decode the existing level to get current data level_data = level_encoder.decode(encoded_level) # Update only the specified parameters (keep existing if not specified) if new_name is not None: level_data["name"] = new_name if new_description is not None: level_data["description"] = new_description if max_enemies is not None: level_data["maxEnemies"] = max_enemies if enemy_spawn_chance is not None: level_data["enemySpawnChance"] = enemy_spawn_chance if coin_spawn_chance is not None: level_data["coinSpawnChance"] = coin_spawn_chance # Re-encode with the updated metadata new_encoded_level = level_encoder.encode(level_data) level_url = f"{VIBE_TIDE_CONFIG['web_player_url']}?level={new_encoded_level}" level_data["level_url"] = level_url return { "success": True, "level_data": level_data, "encoded_level": new_encoded_level, "play_url": level_url, "change_summary": "Updated metadata only - tiles unchanged", "message": f"Successfully updated level metadata", } except Exception as e: logger.error(f"Failed to edit level metadata: {e}") return {"success": False, "error": f"Failed to edit level metadata: {str(e)}"} @mcp.tool() async def play_level(encoded_level: str) -> Dict[str, Any]: """Get the URL to play a VibeTide level in the web player. Args: encoded_level: An encoded level string for playing Returns a URL to play the level. """ try: # Decode level to get name try: level_data = level_encoder.decode(encoded_level) level_name = level_data.get("name", "Unnamed Level") except Exception as e: return {"success": False, "error": f"Invalid encoded level: {str(e)}"} play_url = f"{VIBE_TIDE_CONFIG['web_player_url']}?level={encoded_level}" return { "success": True, "play_url": play_url, "level_name": level_name, "web_player_url": VIBE_TIDE_CONFIG["web_player_url"], "message": f"Ready to play '{level_name}' at: {play_url}", } except Exception as e: logger.error(f"Failed to prepare level for playing: {e}") return { "success": False, "error": f"Failed to prepare level for playing: {str(e)}", } @mcp.tool() async def edit_level_tile( encoded_level: str, row: int, col: int, new_tile_type: int ) -> Dict[str, Any]: """Edit a single tile in a VibeTide level. Args: encoded_level: An encoded level string from a URL or sharing link row: Row index (0-based, from top) col: Column index (0-based, from left) new_tile_type: New tile type (0-7, see tile legend) Returns the modified level data with the single tile changed. """ try: # Decode the level first try: level_data = level_encoder.decode(encoded_level) except Exception as e: return { "success": False, "error": f"Failed to decode level: {str(e)}", } tiles = level_data["tiles"] if not isinstance(tiles, list) or not tiles: return {"success": False, "error": "Invalid tiles array"} height = len(tiles) width = len(tiles[0]) if tiles else 0 # Validate coordinates if row < 0 or row >= height: return { "success": False, "error": f"Invalid row {row} - must be 0-{height-1}", } if col < 0 or col >= width: return { "success": False, "error": f"Invalid column {col} - must be 0-{width-1}", } # Validate tile type if new_tile_type < 0 or new_tile_type > 7: return { "success": False, "error": f"Invalid tile type {new_tile_type} - must be 0-7", } # Make the edit old_tile_type = tiles[row][col] tiles[row][col] = new_tile_type # Create new level data edited_level = level_data.copy() edited_level["tiles"] = tiles # Generate encoded version try: encoded_level = level_encoder.encode(edited_level) play_url = f"{VIBE_TIDE_CONFIG['web_player_url']}?level={encoded_level}" except Exception as e: logger.warning(f"Failed to encode edited level: {e}") encoded_level = None play_url = None # Generate visualization visualization = visualize_level(edited_level) old_tile_name = TILE_TYPES.get(old_tile_type, {"name": "Unknown"})["name"] new_tile_name = TILE_TYPES.get(new_tile_type, {"name": "Unknown"})["name"] return { "success": True, "level_data": edited_level, "encoded_level": encoded_level, "play_url": play_url, "visualization": visualization, "change_summary": f"Changed tile at ({row}, {col}) from {old_tile_name} to {new_tile_name}", "message": f"Successfully edited tile at position ({row}, {col})", } except Exception as e: logger.error(f"Failed to edit level tile: {e}") return {"success": False, "error": f"Failed to edit level tile: {str(e)}"} @mcp.tool() async def edit_level_row( encoded_level: str, row: int, new_row_tiles: List[int] ) -> Dict[str, Any]: """Edit an entire row in a VibeTide level. Args: encoded_level: An encoded level string from a URL or sharing link row: Row index to replace (0-based, from top) new_row_tiles: Array of tile types for the new row (each 0-7) Returns the modified level data with the entire row replaced. """ try: # Decode the level first try: level_data = level_encoder.decode(encoded_level) except Exception as e: return { "success": False, "error": f"Failed to decode level: {str(e)}", } tiles = level_data["tiles"] if not isinstance(tiles, list) or not tiles: return {"success": False, "error": "Invalid tiles array"} height = len(tiles) width = len(tiles[0]) if tiles else 0 # Validate row index if row < 0 or row >= height: return { "success": False, "error": f"Invalid row {row} - must be 0-{height-1}", } # Validate new row tiles if not isinstance(new_row_tiles, list): return {"success": False, "error": "new_row_tiles must be a list"} if len(new_row_tiles) != width: return { "success": False, "error": f"new_row_tiles length {len(new_row_tiles)} doesn't match level width {width}", } # Validate tile types for i, tile_type in enumerate(new_row_tiles): if not isinstance(tile_type, int) or tile_type < 0 or tile_type > 7: return { "success": False, "error": f"Invalid tile type {tile_type} at position {i} - must be integer 0-7", } # Make the edit old_row = tiles[row].copy() tiles[row] = new_row_tiles.copy() # Create new level data edited_level = level_data.copy() edited_level["tiles"] = tiles # Generate encoded version try: encoded_level = level_encoder.encode(edited_level) play_url = f"{VIBE_TIDE_CONFIG['web_player_url']}?level={encoded_level}" except Exception as e: logger.warning(f"Failed to encode edited level: {e}") encoded_level = None play_url = None # Generate visualization visualization = visualize_level(edited_level) return { "success": True, "level_data": edited_level, "encoded_level": encoded_level, "play_url": play_url, "visualization": visualization, "change_summary": f"Replaced row {row}: {old_row} → {new_row_tiles}", "message": f"Successfully edited row {row}", } except Exception as e: logger.error(f"Failed to edit level row: {e}") return {"success": False, "error": f"Failed to edit level row: {str(e)}"} @mcp.tool() async def edit_entire_level( level_data: Dict[str, Any], new_tiles: List[List[int]], new_name: Optional[str] = None, new_description: Optional[str] = None, max_enemies: Optional[int] = None, enemy_spawn_chance: Optional[float] = None, coin_spawn_chance: Optional[float] = None, ) -> Dict[str, Any]: """Edit an entire VibeTide level, replacing all tiles and optionally metadata. Args: level_data: The original level data (for reference) new_tiles: 2D array of tile types (each 0-7) new_name: New name for the level (optional) new_description: New description for the level (optional) max_enemies: Maximum enemies parameter (1-10, optional) enemy_spawn_chance: Enemy spawn chance percentage (0-100, optional) coin_spawn_chance: Coin spawn chance percentage (0-100, optional) Returns the completely modified level data. """ try: # Validate new tiles array if not isinstance(new_tiles, list) or not new_tiles: return {"success": False, "error": "new_tiles must be a non-empty 2D array"} # Validate dimensions height = len(new_tiles) width = len(new_tiles[0]) if new_tiles else 0 if width == 0: return {"success": False, "error": "new_tiles rows cannot be empty"} # Validate all rows have same width and contain valid tile types for row_idx, row in enumerate(new_tiles): if not isinstance(row, list): return {"success": False, "error": f"Row {row_idx} is not a list"} if len(row) != width: return { "success": False, "error": f"Row {row_idx} has length {len(row)}, expected {width}", } for col_idx, tile_type in enumerate(row): if not isinstance(tile_type, int) or tile_type < 0 or tile_type > 7: return { "success": False, "error": f"Invalid tile type {tile_type} at ({row_idx}, {col_idx}) - must be integer 0-7", } # Create new level data edited_level = { "tiles": [row.copy() for row in new_tiles], "width": width, "height": height, "name": new_name or level_data.get("name", "Edited Level"), "description": new_description or level_data.get("description", "A custom edited level"), } # Add game parameters if provided if max_enemies is not None: if not isinstance(max_enemies, int) or max_enemies < 0 or max_enemies > 10: return {"success": False, "error": "max_enemies must be integer 0-10"} edited_level["maxEnemies"] = max_enemies elif "maxEnemies" in level_data: edited_level["maxEnemies"] = level_data["maxEnemies"] if enemy_spawn_chance is not None: if ( not isinstance(enemy_spawn_chance, (int, float)) or enemy_spawn_chance < 0 or enemy_spawn_chance > 100 ): return { "success": False, "error": "enemy_spawn_chance must be number 0-100", } edited_level["enemySpawnChance"] = float(enemy_spawn_chance) elif "enemySpawnChance" in level_data: edited_level["enemySpawnChance"] = level_data["enemySpawnChance"] if coin_spawn_chance is not None: if ( not isinstance(coin_spawn_chance, (int, float)) or coin_spawn_chance < 0 or coin_spawn_chance > 100 ): return { "success": False, "error": "coin_spawn_chance must be number 0-100", } edited_level["coinSpawnChance"] = float(coin_spawn_chance) elif "coinSpawnChance" in level_data: edited_level["coinSpawnChance"] = level_data["coinSpawnChance"] # Generate encoded version try: encoded_level = level_encoder.encode(edited_level) play_url = f"{VIBE_TIDE_CONFIG['web_player_url']}?level={encoded_level}" except Exception as e: logger.warning(f"Failed to encode edited level: {e}") encoded_level = None play_url = None # Generate visualization visualization = visualize_level(edited_level) return { "success": True, "level_data": edited_level, "encoded_level": encoded_level, "play_url": play_url, "visualization": visualization, "change_summary": f"Replaced entire level: {width}x{height} tiles", "message": f"Successfully edited entire level '{edited_level['name']}'", } except Exception as e: logger.error(f"Failed to edit entire level: {e}") return {"success": False, "error": f"Failed to edit entire level: {str(e)}"} @mcp.tool() async def create_level( level_name: str, description: str, tiles: List[List[int]], width: int = 50, height: int = 22, maxEnemies: int = 5, enemySpawnChance: float = 10.0, coinSpawnChance: float = 15.0, ) -> Dict[str, Any]: """You are an AI assistant tasked with creating fast, fun, and playable levels for the VibeTide 2D platformer game. Your role is to design levels that are engaging and balanced while adhering to specific rules and guidelines. Here are the critical rules you must follow: 1. Create levels that are EXACTLY target_width tiles wide and target_height tiles tall (default 50×22). 2. The player must spawn at the LEFTMOST solid platform in the bottom half of the level. 3. Leave 3-4 empty rows above the starting platform for jumping. 4. Design the level as a LEFT-TO-RIGHT platformer, not a maze. Use the following tile types in your level design: 0 = Empty 1 = Grass 2 = Rock 3 = Yellow 4 = Ice 5 = Red 6 = Spikes 7 = Water Follow these level design guidelines: - Bottom half: Focus on main platforms and gameplay elements - Top half: Keep mostly empty for air and jumping - Create jumpable gaps (maximum 3-4 tiles apart) - Ensure a clear left-to-right progression For difficulty parameters: - Easy levels: maxEnemies=2-3, enemySpawnChance=5-10, coinSpawnChance=20-30 - Medium levels: maxEnemies=4-6, enemySpawnChance=10-15, coinSpawnChance=15-20 - Hard levels: maxEnemies=7-10, enemySpawnChance=15-25, coinSpawnChance=10-15 Ensure that your level is exactly the specified dimensions and follows all the design rules for playability. Args: level_name: The name of the level description: A brief description of the level tiles: A 2D array of tile types width: The width of the level height: The height of the level maxEnemies: The maximum number of enemies in the level enemySpawnChance: The chance of an enemy spawning coinSpawnChance: The chance of a coin spawning """ try: # Create the level data dictionary level_data = { "name": level_name, "description": description, "tiles": tiles, "width": width, "height": height, "maxEnemies": maxEnemies, "enemySpawnChance": enemySpawnChance, "coinSpawnChance": coinSpawnChance, } # Pass the complete level_data dictionary to the encoder encoded_level = level_encoder.encode(level_data) play_url = f"{VIBE_TIDE_CONFIG['web_player_url']}?level={encoded_level}" return { "success": True, "level_data": level_data, "encoded_level": encoded_level, "play_url": play_url, "message": f"Successfully created level '{level_name}'", } except Exception as e: logger.error(f"Failed to create level with AI: {e}") return {"success": False, "error": f"Failed to create level with AI: {str(e)}"} @mcp.tool() async def decode_level_from_url(encoded_level: str) -> Dict[str, Any]: """Decode a VibeTide level from an encoded URL string. Args: encoded_level: The encoded level string from a URL or sharing link Returns the decoded level data with visualization. """ try: level_data = level_encoder.decode(encoded_level) visualization = visualize_level(level_data) play_url = f"{VIBE_TIDE_CONFIG['web_player_url']}?level={encoded_level}" return { "success": True, "level_data": level_data, "visualization": visualization, "play_url": play_url, "encoded_level": encoded_level, "message": f"Successfully decoded level: {level_data.get('name', 'Unnamed Level')}", } except Exception as e: logger.error(f"Failed to decode level: {e}") return {"success": False, "error": f"Failed to decode level: {str(e)}"} @mcp.tool() async def get_tile_reference() -> Dict[str, Any]: """Get the reference guide for VibeTide tile types. Returns information about all available tile types and their properties. """ return { "success": True, "tile_types": TILE_TYPES, "message": "VibeTide Tile Reference Guide", "usage_notes": [ "Tile types are represented by integers 0-7", "Use these numbers when editing levels", "Empty tiles (0) represent walkable air space", "Platform tiles (1-3) are solid ground", "Special tiles (4-7) have unique properties", ], } def main(): """Main entry point for the VibeTide MCP Server""" transport_mode = os.getenv("TRANSPORT", "stdio") logger.info("Starting VibeTide MCP Server...") logger.info(f"Transport mode: {transport_mode}") logger.info(f"Web Player URL: {VIBE_TIDE_CONFIG['web_player_url']}") logger.info("Available tools:") logger.info(" - view_level: View level with ASCII visualization") logger.info(" - view_level_image: View level as beautiful PNG image") logger.info(" - play_level: Get URL to play level") logger.info(" - edit_level_tile: Edit single tile") logger.info(" - edit_level_row: Edit entire row") logger.info(" - edit_entire_level: Edit complete level") logger.info(" - create_level: Use AI to create/modify levels") logger.info(" - decode_level_from_url: Decode level from URL") logger.info(" - get_tile_reference: Get tile type reference") logger.info("") logger.info("Note: Use view_level_image for much better visuals!") logger.info(" AI level generation uses VibeTide agent prompts in docstrings") logger.info(" for MCP client integration") if transport_mode == "http": # HTTP mode for Smithery deployment logger.info("Starting in HTTP mode...") # Setup Starlette app with CORS for cross-origin requests app = mcp.streamable_http_app() # Add CORS middleware for browser based clients app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], expose_headers=["mcp-session-id", "mcp-protocol-version"], max_age=86400, ) # Use Smithery-required PORT environment variable port = int(os.environ.get("PORT", 8081)) logger.info(f"Listening on port {port}") uvicorn.run(app, host="0.0.0.0", port=port, log_level="info") else: # STDIO mode for local development and backward compatibility logger.info("Starting in STDIO mode...") mcp.run() # Run the server when the script is executed directly if __name__ == "__main__": main()

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/banjtheman/vibe_tide_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server