$title =

Minecraft Server Deployment

;

$content = [

A recent project I have been working on is the implementation of a Minecraft Java Edition server, using Curseforge with neoforge to run a modded java server for a group of friends. We purchased a server together, and we decided that the administration should be handed by myself as I have experience in all the technology stack around the server. I used this project for my Capstone Project class as part of my Masters of IT program.

Hardware

We purchased an Intel-based server, and some basic components. One of the players who is helping fund the project also had a spare PC case we could use to save costs.

  • Intel(R) Core(TM) Ultra 7 265K
  • 64G Corsair RAM
  • 1TB Western Digital Black SSD
  • ASRock Z890 motherboard

Network

The interesting part starts here. Since I have a business class connection at my home, hosting it here made additional sense since that came with a backup UPS as well for the ISP devices. The server now has a home with static IP addressing, business-class internet that includes firewalls upstream for DoS/DDoS protection and vulnerability filtering.

The host is plugged in with a public IP on the NIC. I have a test Palo Alto Firewall device from personal testing from some time ago, it’s an aged 200 series Palo Alto. I have a vWire deployed with the server’s interface plugged into the “Near” side of the firewall’s vWire, and the “Far” side plugs directly into the cable modem which runs the ISP firewall.

Operating System

The server runs Ubuntu 24, with a bare metal installation for the production single use case. This project however saw the creation of a dockerized container version of the minecraft server.

Writing the Dockerfile was difficult since the Curseforge modest uses a multi-stage deployment style, where it has to configure the application with the first run of the container, and then actually run the Minecraft Server on the second container boot up. This allows for it to pull neoforge java mods from an online repostiory using relevant versions for the mods configured. Curseforge provides a web interface to select the mods, and then download the server pack for that mod set. This is referred to in the Dockerfile as ServerFiles-3.0.zip as our modset “All the Mods 10” is on version 3. You’ll note below that “25575” is expose as well as the typical Minecraft server port “25565”. This additional port is a key component to additional automation and monitoring capabilities added later.

Dockerfile
FROM ubuntu:latest AS builder
RUN apt update && apt install openjdk-21-jre unzip -y
RUN mkdir -p /app/world
WORKDIR /app/world
ADD ./ServerFiles-3.0.zip /app
RUN unzip /app/ServerFiles-3.0.zip -d /app/world/ && chmod +x /app/world/startserver.sh

RUN echo "eula=true" > /app/world/eula.txt
RUN /bin/bash -c ' \
    ATM10_INSTALL_ONLY=true /app/world/startserver.sh 2>&1 | tee /dev/stderr; \
    echo "Initialization complete. Exiting build stage."; \
    exit 0'

# stage 2 using files as base image
FROM ubuntu:latest AS final
COPY --from=builder /app/world /app/world
COPY server.properties /app/world/server.properties
RUN apt -y update && apt -y install openjdk-21-jre
EXPOSE 25565
EXPOSE 25575
CMD ["/bin/bash", "-c", "/app/world/startserver.sh"]

Automation tools

The Dockerfile was the start of the automation deployment. The automation toolchain was expanded upon with docker compose, which provides automatic management of the container in terms of startup and shutdown, and manages the port exposure without having to manually specify settings during run. Instead, invocation is using docker compose up with the following code.

Docker Compose
services:
  mc:
    build:
      context: .
      dockerfile: Dockerfile
      target: final
    volumes:
      - minecraft-world:/app/world
    ports:
      - "25565:25565"
      - "25575:25575"
volumes:
  minecraft-world:
    driver: local

Monitoring

Originally, the project sought to integrate Zabbix monitoring for all layers. The project succeeded in getting the physical host monitored, which in this case runs the java binaries directly (but it could run docker with the containerized version).

LLM Interation

What’s some 2025 conversation – WITHOUT AI?! Please, do find a way to get past my sarcasm. Since the industry is currently hot over the new model context protocol to allow for API tooling to integrate with LLMs, I found it would be prudent to try to integrate this.

Earlier I mentioned the additional 25575 port – this was unrelated to Zabbix, and instead related to “Minecraft rcon”, which is a server side software that provides a network socket for remote commands to be run in-game. I found a GitHub repo barneygale/MCRcon that implements the rcon protocol for Minecraft in Python. With the liberal licensure, I took this code into a new program and developed ‘mclib’ which wrapped a new class around this mcrcon object above.

mcrcon shell
from mcrcon_shell.mcrcon import *  # Corrected import path
import socket

class MinecraftRconClient:
    def __init__(self, host, port, password):
        self.host = host
        self.port = int(port)
        self.password = password
        self.rcon = self.connect()

    def getserver_info(self):
        return {
            "host": self.host,
            "port": self.port,
        }
    
    def connect(self) -> bool:
        """ Connects to the Minecraft server using RCON.
         Returns True if the connection is successful, False otherwise.
        """
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.connect((self.host, self.port))
        result = login(sock, self.password)
        if not result:
            print("Incorrect rcon password")
            return False
        self.rcon = sock
        return True

    def run(self, cmd) -> str:
        """ returns the output of the command """
        if not self.rcon:
            print("Not connected to the server")
            return
        response = command(self.rcon, cmd)
        print(response)
        return response
    
    def disconnect(self):
        self.rcon.close()
        self.rcon = None
        print("Disconnected from the server")
        

This gives me a nifty way to start making an MCP for Minecraft Java Server. I started writing an MCP that uses FastMCP as the local python server, and it provides an HTTP server that a local LLM like Claude can interface with. A majority of this code relied on command validation for the minecraft commands as they approach common language like data entity get <player> , or summon <thing> <coords> which both provide a large syntax tree to parse through. For security and simplicity, I hardcoded the allowed strings the LLM can use.

mcp code
#!/usr/bin/env python3
"""
Minecraft Java Server MCP Server using mclib

This MCP server provides tools for common Minecraft Java server commands
using RCON protocol through the mclib library.
"""

import os
import json
import logging
from typing import Any, Dict, List, Optional, Union
from dataclasses import dataclass
from enum import StrEnum

from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from mcrcon_shell.mclib import MinecraftRconClient

# Load environment variables
load_dotenv()

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Initialize FastMCP
mcp = FastMCP("Minecraft Java Server")

# Global RCON client instance
rcon_client: Optional['RConClient'] = None

class MinecraftCommandBuilder:
    """Builder for Minecraft commands"""

    @staticmethod
    def teleport(player: str, x: float, y: float, z: float) -> str:
        """Build teleport command"""
        return f"tp {player} {x} {y} {z}"

    @staticmethod
    def ban_player(player: str, reason: Optional[str] = None) -> str:
        """Build ban command"""
        if reason:
            return f"ban {player} {reason}"
        return f"ban {player}"

    @staticmethod
    def unban_player(player: str) -> str:
        """Build unban command"""
        return f"pardon {player}"

    @staticmethod
    def kick_player(player: str, reason: Optional[str] = None) -> str:
        """Build kick command"""
        if reason:
            return f"kick {player} {reason}"
        return f"kick {player}"

    @staticmethod
    def deop_player(player: str) -> str:
        """Build deop command"""
        return f"deop {player}"

    @staticmethod
    def op_player(player: str) -> str:
        """Build op command"""
        return f"op {player}"

    @staticmethod
    def locate_player(player: str) -> tuple[str, str]:
        """Build locate command"""
        return (f"data get entity {player} Pos", 
                f"data get entity {player} Dimension",)
    
    @staticmethod
    def give_item(player: str, item: str, count: int = 1) -> str:
        """Build give command"""
        return f"give {player} {item} {count}"
    
    @staticmethod
    def summon_entity(entity: str, x: float, y: float, z: float) -> str:
        """Build summon command"""
        return f"summon {entity} {x} {y} {z}"
    
    @staticmethod
    def set_weather(weather: str, duration: int, unit: str) -> str:
        """Build weather command"""
        if unit not in ["d", "s", "t"]:
            unit = "t"  # Default to ticks
        return f"weather {weather} {duration}{unit}"
    
    @staticmethod
    def set_time(time: str) -> str:
        """Build time command"""
        match time:
            case "daytime" | "day":
                return "time set day"
            case "nighttime" | "night":
                return "time set night"
            case "noon":
                return "time set noon"
            case "midnight":
                return "time set midnight"
            
    @staticmethod
    def add_time(increment: int, unit: str) -> str:
        """Build add time command"""
        if unit not in ["d", "s", "t"]:
            unit = "t"
        return f"time add {increment}{unit}"
        
    @staticmethod
    def set_gamemode(gamemode: str, player: Optional[str] = None) -> str:
        """Build gamemode command"""
        if player:
            return f"gamemode {gamemode} {player}"
        return f"gamemode {gamemode}"
    
    @staticmethod
    def chat_message(message: str) -> str:
        """Build chat command"""
        message = "[API] " + message
        return f"say {message}"
    
    HELP = "help"
    LIST = "list"
    SAVE = "save-all"
  
class MinecraftItem(StrEnum):
    """Enum for Minecraft items"""
    STONE = "minecraft:stone"
    GRASS_BLOCK = "minecraft:grass_block"
    DIRT = "minecraft:dirt"
    COBBLESTONE = "minecraft:cobblestone"
    OAK_PLANKS = "minecraft:oak_planks"
    OAK_LOG = "minecraft:oak_log"
    IRON_INGOT = "minecraft:iron_ingot"
    GOLD_INGOT = "minecraft:gold_ingot"
    DIAMOND = "minecraft:diamond"
    EMERALD = "minecraft:emerald"
    WOODEN_SWORD = "minecraft:wooden_sword"
    WOODEN_AXE = "minecraft:wooden_axe"
    WOODEN_PICKAXE = "minecraft:wooden_pickaxe"
    WOODEN_SHOVEL = "minecraft:wooden_shovel"
    IRON_SWORD = "minecraft:iron_sword"
    IRON_AXE = "minecraft:iron_axe"
    IRON_PICKAXE = "minecraft:iron_pickaxe"
    IRON_SHOVEL = "minecraft:iron_shovel"
    GOLD_SWORD = "minecraft:gold_sword"
    GOLD_AXE = "minecraft:gold_axe"
    GOLD_PICKAXE = "minecraft:gold_pickaxe"
    GOLD_SHOVEL = "minecraft:gold_shovel"
    DIAMOND_SWORD = "minecraft:diamond_sword"
    DIAMOND_AXE = "minecraft:diamond_axe"
    DIAMOND_PICKAXE = "minecraft:diamond_pickaxe"
    DIAMOND_SHOVEL = "minecraft:diamond_shovel"

class MinecraftEntity(StrEnum):
    """Enum for Minecraft entities"""
    PLAYER = "minecraft:player"
    CREEPER = "minecraft:creeper"
    SKELETON = "minecraft:skeleton"
    SPIDER = "minecraft:spider"
    ZOMBIE = "minecraft:zombie"
    ENDERMAN = "minecraft:enderman"
    COW = "minecraft:cow"
    SHEEP = "minecraft:sheep"
    PIG = "minecraft:pig"
    CHICKEN = "minecraft:chicken"
    VILLAGER = "minecraft:villager"

@dataclass
class RConClient:
    """RConClient for Minecraft Java Server
    This class handles the connection to the Minecraft server using RCON protocol.
    It allows sending commands and receiving responses from the server.
    It manages the socket connection and provides methods to interact with the server.
    """

    host: str
    port: int
    password: str
    name: str = "default"

    def __post_init__(self):
        """Initialize connection with the provided parameters"""
        self.host = os.getenv('MC_HOST', "localhost")
        self.port = int(os.getenv('MC_PORT', "25575"))
        self.password = os.getenv('MC_PASSWORD', "your_password")
        self.connected: bool = False
        self.client = MinecraftRconClient(self.host, self.port, self.password)

    def __repr__(self):
        return f"RConClient(name={self.name}, host={self.host}, port={self.port})"
    
    def _connect(self):
        """Establish a connection to the Minecraft server using RCON"""
        try:
            if not self.connected:
                self.connected = self.client.connect()
                if self.connected:
                    logger.info(f"Connected to Minecraft server: {self.name} at {self.host}:{self.port}")
                else:
                    logger.error(f"Failed to connect to Minecraft server: {self.name}")
                    raise ConnectionError(f"Failed to connect to Minecraft server: {self.name}")
        except Exception as e:
            logger.error(f"Failed to connect to Minecraft server: {self.name}. Error: {e}")
            self.connected = False
            raise ConnectionError(f"Failed to connect to Minecraft server: {self.name}. Error: {e}")

    def _send_command(self, command: str) -> Union[str, None]:
        """Send a command to the Minecraft server and return the response"""
        if not self.connected:
            logger.error("Not connected to the Minecraft server.")
            return "Error: Not connected to the Minecraft server."

        try:
            logger.info(f"Sending command to Minecraft server: {self.name} - {command}")
            response = self.client.run(command)
            logger.info(f"Command sent: {command}, Response type: {type(response)}, Response: {response}")
            
            # Handle different response types
            if response is None:
                return "No response from server"
            elif isinstance(response, str):
                return response if response.strip() else "Empty response from server"
            else:
                return str(response)
                
        except Exception as e:
            logger.error(f"Failed to send command '{command}': {e}")
            return f"Command failed: {str(e)}"
    
    def _close_connection(self):
        """Close the connection to the Minecraft server"""
        if self.connected:
            try:
                self.client.disconnect()
                logger.info(f"Disconnected from Minecraft server: {self.name}")
            except Exception as e:
                logger.error(f"Failed to disconnect from Minecraft server: {self.name}. Error: {e}")
            finally:
                self.connected = False
        else:
            logger.warning("No active connection to close.")

    def send_server_command(self, command: str, batch_count: int = 1) -> list[Optional[str]]:
        """Send a command to the Minecraft server and return the response"""
        responses = []
        try:
            self._connect()
            for i in range(batch_count):
                logger.info(f"Executing command batch {i + 1}/{batch_count}: {command}")
                response = self._send_command(command)
                responses.append(response)
        except Exception as e:
            logger.error(f"Error in send_server_command: {e}")
            responses.append(f"Error: {str(e)}")
        finally:
            self._close_connection()
        return responses
    

class ItemValidator:
    """Validator for Minecraft items"""
    
    @staticmethod
    def validate_item(item: str) -> tuple[bool, str]:
        """Validate if an item exists in Minecraft
        
        Returns:
            tuple[bool, str]: (is_valid, validated_item_or_error_message)
        """
        # Check if it's already a valid enum value
        try:
            minecraft_item = MinecraftItem(item)
            return True, minecraft_item.value
        except ValueError:
            pass
        
        # Check if it's a valid item without minecraft: prefix
        if not item.startswith("minecraft:"):
            try:
                minecraft_item = MinecraftItem(f"minecraft:{item}")
                return True, minecraft_item.value
            except ValueError:
                pass
        
        # Check case-insensitive match
        item_upper = item.upper()
        for minecraft_item in MinecraftItem:
            if minecraft_item.name == item_upper:
                return True, minecraft_item.value
            if minecraft_item.value.upper() == item_upper:
                return True, minecraft_item.value
        
        return False, f"Invalid item: {item}"
    
    @staticmethod
    def get_available_items() -> List[str]:
        """Get list of all available items"""
        return [item.value for item in MinecraftItem]
    
    @staticmethod
    def search_items(query: str) -> List[str]:
        """Search for items containing the query string"""
        query_lower = query.lower()
        matches = []
        for item in MinecraftItem:
            if query_lower in item.name.lower() or query_lower in item.value.lower():
                matches.append(item.value)
        return matches

def get_client() -> RConClient:
    """Get or create the RCON client"""
    global rcon_client
    if rcon_client is None:
        rcon_client = RConClient(
            host=os.getenv('MC_HOST', "localhost"),
            port=int(os.getenv('MC_PORT', "25575")),
            password=os.getenv('MC_PASSWORD', ""),
            name="default"
        )
    return rcon_client

@mcp.tool()
def give_item_to_player(player: str, item: str, count: int = 1) -> str:
    """Give an item to a player with comprehensive validation"""
    if not player.strip():
        return "Player name cannot be empty"
    
    is_valid, result = ItemValidator.validate_item(item)
    if not is_valid:
        return result  # Return error message
    
    command = MinecraftCommandBuilder.give_item(player, result, count)
    response = get_client().send_server_command(command)[0]
    return response or f"Failed to give {item} to {player}"

@mcp.tool()
def search_minecraft_items(query: str) -> str:
    """Search for Minecraft items by name"""
    if not query.strip():
        return "Search query cannot be empty"
    
    matches = ItemValidator.search_items(query)
    if not matches:
        return f"No items found matching '{query}'"
    
    return f"Items matching '{query}':\n" + "\n".join(matches)

@mcp.tool()
def list_all_items() -> str:
    """List all available Minecraft items"""
    items = ItemValidator.get_available_items()
    return f"Available items ({len(items)} total):\n" + "\n".join(items)

@mcp.tool()
def validate_minecraft_item(item: str) -> str:
    """Validate if an item exists in Minecraft"""
    is_valid, result = ItemValidator.validate_item(item)
    if is_valid:
        return f"✅ Valid item: {result}"
    else:
        # Suggest similar items
        suggestions = ItemValidator.search_items(item.split(":")[-1])
        suggestion_text = f"\nSuggestions: {suggestions[:5]}" if suggestions else ""
        return f"❌ {result}{suggestion_text}"
    
@mcp.tool()
def teleport_player(player: str, x: float, y: float, z: float) -> str:
    """Teleport a player to specified coordinates"""
    if not player.strip():
        return "Player name cannot be empty"
    
    command = MinecraftCommandBuilder.teleport(player, x, y, z)
    response = get_client().send_server_command(command)[0]
    return response or f"Failed to teleport {player} to {x}, {y}, {z}"

@mcp.tool()
def summon_entity(entity: str, x: float, y: float, z: float, count: int = 1) -> str:
    """Summon an entity at specified coordinates"""
    responses = []
    if not entity.strip():
        return "Entity name cannot be empty"
    else:
        try:
            MinecraftEntity(entity)  # Validate entity
        except ValueError:
            entity = "minecraft:" + entity if not entity.startswith("minecraft:") else entity
            try:
                MinecraftEntity(entity)  # Validate again with prefix
            except ValueError:
                return f"Invalid entity: {entity}. Use a valid Minecraft entity name."
            return f"Invalid entity: {entity}. Use a valid Minecraft entity name."
        
    command = MinecraftCommandBuilder.summon_entity(entity, x, y, z)
    for i in range(count):
        response = get_client().send_server_command(command)[0]
        if response:
            responses.append(f"Summoned {entity} at {x}, {y}, {z} (Batch {i + 1})")
        else:
            responses.append(f"Failed to summon {entity} at {x}, {y}, {z} (Batch {i + 1})")
    return "\n".join(responses)

@mcp.tool()
def set_weather(weather: str, duration: int, unit: str) -> str:
    """Set the weather in the Minecraft world"""
    if weather not in ["clear", "rain", "thunder"]:
        return "Invalid weather type. Use 'clear', 'rain', or 'thunder'."
    
    command = MinecraftCommandBuilder.set_weather(weather, duration, unit)
    response = get_client().send_server_command(command)[0]
    return response or f"Failed to set weather to {weather} for {duration}{unit}"

@mcp.tool()
def add_time(increment: int, unit: str) -> str:
    """Add time to the Minecraft world"""
    if unit not in ["d", "s", "t"]:
        return "Invalid time unit. Use 'd' for days, 's' for seconds, or 't' for ticks."
    
    command = MinecraftCommandBuilder.add_time(increment, unit)
    response = get_client().send_server_command(command)[0]
    return response or f"Failed to add {increment}{unit} to the game time"

@mcp.tool()
def get_player_location(player: str) -> str:
    """Get the location of a player"""
    if not player.strip():
        return "Player name cannot be empty"
    
    pos_command, dim_command = MinecraftCommandBuilder.locate_player(player)
    pos_response = get_client().send_server_command(pos_command)[0]
    dim_response = get_client().send_server_command(dim_command)[0]
    
    if pos_response and dim_response:
        return f"{player} is at {pos_response} in dimension {dim_response}"
    else:
        return f"Failed to get location for player {player}"

@mcp.tool()
def deop_player(player: str) -> str:
    """De-op a player"""
    if not player.strip():
        return "Player name cannot be empty"
    
    command = MinecraftCommandBuilder.deop_player(player)
    response = get_client().send_server_command(command)[0]
    return response or f"Failed to de-op {player}"

@mcp.tool()
def op_player(player: str) -> str:
    """Op a player"""
    if not player.strip():
        return "Player name cannot be empty"
    
    command = MinecraftCommandBuilder.op_player(player)
    response = get_client().send_server_command(command)[0]
    return response or f"Failed to op {player}"

@mcp.tool()
def chat_message(message: str) -> str:
    """Send a chat message to the Minecraft server"""
    if not message.strip():
        return "Message cannot be empty"
    
    command = MinecraftCommandBuilder.chat_message(message)
    response = get_client().send_server_command(command)[0]
    return response or f"Failed to send message: {message}"

@mcp.tool()
def ban_player(player: str, reason: Optional[str] = None) -> str:
    """Ban a player from the Minecraft server"""
    if not player.strip():
        return "Player name cannot be empty"
    
    command = MinecraftCommandBuilder.ban_player(player, reason)
    response = get_client().send_server_command(command)[0]
    return response or f"Failed to ban {player}"

@mcp.tool()
def unban_player(player: str) -> str:
    """Unban a player from the Minecraft server"""
    if not player.strip():
        return "Player name cannot be empty"
    
    command = MinecraftCommandBuilder.unban_player(player)
    response = get_client().send_server_command(command)[0]
    return response or f"Failed to unban {player}"

@mcp.tool()
def save_world() -> str:
    """Save the Minecraft world"""
    command = MinecraftCommandBuilder.SAVE
    response = get_client().send_server_command(command)[0]
    return response or "Failed to save the world"

@mcp.tool()
def list_players() -> str:
    """List all players currently online on the Minecraft server"""
    command = MinecraftCommandBuilder.LIST
    response = get_client().send_server_command(command)[0]
    if response:
        return f"Online players:\n{response}"
    else:
        return "Failed to retrieve player list"

@mcp.tool()
def test_connection() -> str:
    """Test the connection to the Minecraft server and return debug info"""
    try:
        client = get_client()
        logger.info(f"Testing connection to {client.host}:{client.port}")
        
        # Test with a simple command
        command = "help"
        response = client.send_server_command(command)[0]
        
        if response:
            return f"✅ Connection successful!\nServer response: {response[:100]}..."
        else:
            return f"❌ Connection failed - no response from server\nHost: {client.host}:{client.port}"
            
    except Exception as e:
        return f"❌ Connection error: {str(e)}"

def main():
    """Main function to run the MCP server"""
    mcp.run()

if __name__ == "__main__":
    main()

I ended up trying to use LM Studio on my MacBook M4 to use either Google Gemma 3, or Deepseek running locally to use the MCP but the computation is real slow, despite Apple having a unified memory approach. This causes me to use Claude Desktop if the commands I want to have the LLM use the MCP for return complex datasets.

Cool project. Was fun.

];

$date =

;

$category =

;

$author =

;

$previous =

;