from __future__ import annotations import dns.resolver import sys import json import argparse import socket import dataclasses from typing import TYPE_CHECKING from mcstatus import JavaServer, BedrockServer from mcstatus.responses import JavaStatusResponse from mcstatus.motd import Motd if TYPE_CHECKING: from typing_extensions import TypeAlias SupportedServers: TypeAlias = "JavaServer | BedrockServer" PING_PACKET_FAIL_WARNING = ( "warning: contacting {address} failed with a 'ping' packet but succeeded with a 'status' packet,\n" " this is likely a bug in the server-side implementation.\n" ' (note: ping packet failed due to "{ping_exc}")\n' " for more details, see: https://mcstatus.readthedocs.io/en/stable/pages/faq/\n" ) QUERY_FAIL_WARNING = ( "The server did not respond to the query protocol." "\nPlease ensure that the server has enable-query turned on," " and that the necessary port (same as server-port unless query-port is set) is open in any firewall(s)." "\nSee https://minecraft.wiki/w/Query for further information." ) def _motd(motd: Motd) -> str: """Formats MOTD for human-readable output, with leading line break if multiline.""" s = motd.to_ansi() return f"\n{s}" if "\n" in s else f" {s}" def _kind(serv: SupportedServers) -> str: if isinstance(serv, JavaServer): return "Java" elif isinstance(serv, BedrockServer): return "Bedrock" else: raise ValueError(f"unsupported server for kind: {serv}") def _ping_with_fallback(server: SupportedServers) -> float: # bedrock doesn't have ping method if isinstance(server, BedrockServer): return server.status().latency # try faster ping packet first, falling back to status with a warning. ping_exc = None try: return server.ping(tries=1) except Exception as e: ping_exc = e latency = server.status().latency address = f"{server.address.host}:{server.address.port}" print( PING_PACKET_FAIL_WARNING.format(address=address, ping_exc=ping_exc), file=sys.stderr, ) return latency def ping_cmd(server: SupportedServers) -> int: print(_ping_with_fallback(server)) return 0 def status_cmd(server: SupportedServers) -> int: response = server.status() java_res = response if isinstance(response, JavaStatusResponse) else None if not java_res: player_sample = "" elif java_res.players.sample is not None: player_sample = str([f"{player.name} ({player.id})" for player in java_res.players.sample]) else: player_sample = "No players online" if player_sample: player_sample = " " + player_sample print(f"version: {_kind(server)} {response.version.name} (protocol {response.version.protocol})") print(f"motd:{_motd(response.motd)}") print(f"players: {response.players.online}/{response.players.max}{player_sample}") print(f"ping: {response.latency:.2f} ms") return 0 def json_cmd(server: SupportedServers) -> int: data = {"online": False, "kind": _kind(server)} status_res = query_res = exn = None try: status_res = server.status(tries=1) except Exception as e: exn = exn or e try: if isinstance(server, JavaServer): query_res = server.query(tries=1) except Exception as e: exn = exn or e # construct 'data' dict outside try/except to ensure data processing errors # are noticed. data["online"] = bool(status_res or query_res) if not data["online"]: assert exn, "server offline but no exception?" data["error"] = str(exn) if status_res is not None: data["status"] = dataclasses.asdict(status_res) # ensure we are overwriting the motd and not making a new dict field assert "motd" in data["status"], "motd field missing. has it been renamed?" data["status"]["motd"] = status_res.motd.simplify().to_minecraft() if query_res is not None: # TODO: QueryResponse is not (yet?) a dataclass data["query"] = qdata = {} qdata["ip"] = query_res.raw["hostip"] qdata["port"] = query_res.raw["hostport"] qdata["map"] = query_res.map_name qdata["plugins"] = query_res.software.plugins qdata["raw"] = query_res.raw json.dump(data, sys.stdout) return 0 def query_cmd(server: SupportedServers) -> int: if not isinstance(server, JavaServer): print("The 'query' protocol is only supported by Java servers.", file=sys.stderr) return 1 try: response = server.query() except socket.timeout: print(QUERY_FAIL_WARNING, file=sys.stderr) return 1 print(f"host: {response.raw['hostip']}:{response.raw['hostport']}") print(f"software: {_kind(server)} {response.software.version} {response.software.brand}") print(f"motd:{_motd(response.motd)}") print(f"plugins: {response.software.plugins}") print(f"players: {response.players.online}/{response.players.max} {response.players.list}") return 0 def main(argv: list[str] = sys.argv[1:]) -> int: parser = argparse.ArgumentParser( "mcstatus", description=""" mcstatus provides an easy way to query 1.7 or newer Minecraft servers for any information they can expose. It provides three modes of access: query, status, ping and json. """, ) parser.add_argument("address", help="The address of the server.") parser.add_argument("--bedrock", help="Specifies that 'address' is a Bedrock server (default: Java).", action="store_true") subparsers = parser.add_subparsers(title="commands", description="Command to run, defaults to 'status'.") parser.set_defaults(func=status_cmd) subparsers.add_parser("ping", help="Ping server for latency.").set_defaults(func=ping_cmd) subparsers.add_parser("status", help="Prints server status.").set_defaults(func=status_cmd) subparsers.add_parser( "query", help="Prints detailed server information. Must be enabled in servers' server.properties file." ).set_defaults(func=query_cmd) subparsers.add_parser( "json", help="Prints server status and query in json.", ).set_defaults(func=json_cmd) args = parser.parse_args(argv) lookup = JavaServer.lookup if not args.bedrock else BedrockServer.lookup try: server = lookup(args.address) return args.func(server) except (socket.timeout, socket.gaierror, dns.resolver.NoNameservers, ConnectionError, TimeoutError) as e: # catch and hide traceback for expected user-facing errors print(f"Error: {e!r}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())