Diagnostics for a Minecraft server, read from the artifacts it has already produced: its log, its configuration files, its start script, the jars in its plugins directory, its worlds, its crash reports and its access lists. It does not start a server, connect to one, or load a line of plugin code, and it writes nothing except the report you ask for.
That constraint is the point. The moment you need a diagnosis is the moment the server is down, the host has handed you a folder and a log, and nothing can be reproduced. Everything here works on that folder, offline, on the same box that produced it.
| Command | Question it answers |
|---|---|
scan |
Everything below, over one server directory, in one pass. |
log |
What went wrong, which plugin is to blame, and what to do about it. |
config |
Is this server exposed or misconfigured — judged across server.properties, bukkit.yml, spigot.yml and the Paper configs together, not one file at a time? |
flags |
Do the JVM flags in the start script do what whoever wrote them thought? |
plugins |
What is installed, what is broken about the set, and where did each jar come from? |
world |
Is every world intact, is every region file's chunk table pointing at sectors it has, and were the worlds all last saved by the same game version? |
crash |
What did the crash report actually say, whose code was in the trace, and what did the server run with? |
perms |
Who can get in, who is an operator, and does the server verify that anyone is who they claim? |
diff |
What is different between two servers that are supposed to be the same? |
There is also an mcp mode that serves the same audits to an AI coding agent over the Model Context
Protocol, so "why did this server crash" becomes something the agent answers from the log instead of
from your summary of it.
Each audit exists because the server does not tell you the thing you need to know.
- A log tells you what happened, not who did it. Paper names the plugin when a listener throws, and says nothing when a plugin's thread corrupts a chunk twenty minutes later. Findings here carry the attribution and say how it was made — from the logger prefix, from the server's own blame line, or from a stack frame matched against the jars you actually have installed.
- The absence of a warning is not good news. Paper deletes vanilla's
Can't keep up! Is the server overloaded?and replaces the tick loop with catch-up logic, so on Paper that line never appears no matter how far behind the server falls. If your log has it, your log is not from Paper — which is itself worth knowing before you follow Paper-specific advice. - The line that stopped your server can be logged at INFO.
You need to agree to the EULA in order to run the serveris INFO, so a panel that surfaces only errors shows a clean shutdown and no reason. - A setting can do nothing and look like it works.
timeout-time: -1inspigot.ymldoes not disable the watchdog; Paper substitutes 300 seconds. An oldpaper.ymlin the server root is not read at all onceconfig/paper-global.ymlexists. A key that appears twice keeps the last copy silently. folia-supported: trueis a claim, not a check. It is the only thing Folia tests at load, and it is written by the plugin's author.- A world that nobody has entered since the upgrade is still on the old version. Each world records the game version that last wrote it, and nothing on a running server points out that two of its worlds disagree — the conversion happens the moment somebody walks in.
- A crash report outlives the server that wrote it. It carries the game version, the memory
figures and the full
JVM Flags:line, so a file mailed by a host is enough to audit the flags of a machine you will never see. Five reports with one description is a loop, and no single report says so. - Offline mode and an operator list are only dangerous together. With nothing verifying identity,
a player is whoever they say they are, and
ops.jsonis keyed on exactly that. Behind a proxy that forwards identity the same two files are correct, so this is only reported when nothing forwards. - "They are configured the same" is usually false. Two nodes of a network drift by one key, one
plugin version, or one jar that claims a version and is not the same bytes.
diffprints the list and takes no side about which box is the good one.
Every rule states one fact and records where that fact came from — a file and line in Paper or Folia,
a JEP, an API response. serverdoctor rules prints the catalogue with those sources, so a finding can
be argued with instead of believed.
Java 21 or newer. Nothing else; the release jar is self-contained. The network is used only by
plugins --online, and never otherwise.
Download serverdoctor-<version>.jar from the
releases page:
java -jar serverdoctor.jar --help
# The whole server folder, every applicable audit
java -jar serverdoctor.jar scan /srv/mc --host-memory 8G
# Just the log, with plugin attribution from the jars that are installed
java -jar serverdoctor.jar log /srv/mc/logs/latest.log --plugins /srv/mc/plugins
# A rolled log from a host, straight out of the archive
java -jar serverdoctor.jar log logs/2026-08-24-1.log.gz
# Configuration, judged across files
java -jar serverdoctor.jar config /srv/mc
# Flags, from a start script or from something pasted into chat
java -jar serverdoctor.jar flags /srv/mc --host-memory 8G
java -jar serverdoctor.jar flags --command "java -Xms2G -Xmx8G -XX:+UseG1GC -jar paper.jar"
# What is installed, and what the public indexes say each file is
java -jar serverdoctor.jar plugins /srv/mc/plugins --online
# The worlds: level.dat, region files, datapacks, and whether the versions agree
java -jar serverdoctor.jar world /srv/mc
# A crash report somebody sent you, with its own flags audited from the file
java -jar serverdoctor.jar crash crash-2026-08-24_11.04.12-server.txt
# Operators, whitelist, bans and permissions.yml, judged against how identity is verified
java -jar serverdoctor.jar perms /srv/mc
# Two servers that are supposed to be the same
java -jar serverdoctor.jar diff /srv/staging /srv/production
# Every rule, with its rationale and its source
java -jar serverdoctor.jar rules --area log
Output is plain text by default, --format json for a machine, -o file to write it, and
--fail-on critical (the default) decides the exit code so a scan can gate a deploy.
All three samples are real output, captured from the deliberately broken fixtures in
src/test/resources, which is also what CI runs against.
Triage of a log where a plugin blocks the main thread on MySQL until the watchdog kills the server:
$ java -jar serverdoctor.jar log logs/latest.log
log: logs/latest.log
minecraft 26.2
server Paper version 26.2-116-main (MC: 26.2)
startup 19.114s
lines read 62
log entries 33
line format paper
covers 22:30:58 to 22:53:09
[blocker] L001 The watchdog fired: a tick took longer than the timeout
line 39
A tick did not finish before the watchdog timeout, so the server was considered hung. The thread dump that follows names what was running.
at com.mysql.cj.protocol.a.SimplePacketReader.readMessage(SimplePacketReader.java:66)
at com.example.worldtools.storage.MySqlStore.load(MySqlStore.java:88)
at com.example.worldtools.JoinListener.onJoin(JoinListener.java:37)
fix: Read the Server thread dump below the message: the topmost plugin frame is what was blocking. Raising timeout-time hides the crash and converts it into lag.
[blocker] L006 A plugin was compiled for a newer Java release than the server runs
line 9
com.example.regionguard.RegionGuard needs Java 25 (class file major 69); this runtime accepts up to Java 21.
java.lang.UnsupportedClassVersionError: com/example/regionguard/RegionGuard has been compiled by a more recent version of the Java Runtime (class file version 69.0), this version of the Java Runtime only recognizes class file versions up to 65.0
fix: Run the server on the Java release the plugin needs, or install a build of the plugin compiled for yours.
[critical] L002 A plugin touched the server from the wrong thread
line 17 (x2)
"Entity add" ran off the owning thread, reached from com.example.spawnhelper. Thread: Craft Scheduler Thread - 12
at org.spigotmc.AsyncCatcher.catchOp(AsyncCatcher.java:10)
at net.minecraft.server.level.ServerLevel.addFreshEntity(ServerLevel.java:1204)
fix: The named operation has to run on the owning thread: the main thread on Paper, the region or entity scheduler on Folia.
[critical] L003 A plugin's event handler threw
line 27 (x2)
WorldTools v3.0.1 threw while handling PlayerJoinEvent: java.lang.NullPointerException
at com.example.worldtools.JoinListener.onJoin(JoinListener.java:41)
at org.bukkit.plugin.EventExecutor$2.execute(EventExecutor.java:77)
fix: Report it to the named plugin with the stack trace. If it fires on a common event, expect knock-on breakage in unrelated features.
[four more findings omitted here: the load failures, the ambiguous plugin name, the EULA line,
the out-of-memory error and one exception no rule explains]
Summary: 6 blockers, 3 criticals, 2 warnings, 1 info
Note the (x2): the same handler failing twice is one finding with a count, not two entries. A log
from a server that has been crash-looping for a day stays readable.
The configuration audit on the same fixture, which is offline-mode behind a Velocity proxy whose secret was never filled in:
$ java -jar serverdoctor.jar config /srv/mc
server: /srv/mc
server paper
files server.properties, spigot.yml, paper-global.yml
online-mode false
forwarding velocity
distances view 10, simulation 12
level-name world
[blocker] C002 Velocity forwarding is on with an empty secret
config/paper-global.yml:9
proxies.velocity.enabled is true and proxies.velocity.secret is empty.
fix: Copy forwarding.secret from the proxy into proxies.velocity.secret.
[critical] C004 An unsupported-settings toggle is enabled
config/paper-global.yml:15
unsupported-settings.allow-piston-duplication is true, and it ships as false.
fix: Set it back to its default unless you can name why this server needs the old behaviour.
[critical] C006 RCON is enabled with no password
server.properties:5
enable-rcon=true with an empty rcon.password on port 25575.
fix: Set a long random rcon.password, bind RCON to localhost, or turn it off.
[warning] C008 The watchdog timeout is long enough to hide a hang
spigot.yml:4
settings.timeout-time is 900 seconds; the default is 60.
fix: Put it back near the default and diagnose the hang from the thread dump.
[warning] C010 A key appears twice in one file
server.properties:14
online-mode appears 2 times; the server uses "false".
line 3: online-mode=false
line 14: online-mode=false
fix: Delete the copy you do not want.
What is not reported there matters as much: online-mode=false produces no finding, because
Velocity forwarding is configured, and that combination is correct for a backend server. The rule for
offline mode fires only when nothing in front of the server is authenticating players — which is the
false positive this audit exists to avoid.
One crash report, read from a file with no server attached to it. The report carries the flags the server was started with, so the run prints a second report from the same file:
$ java -jar serverdoctor.jar crash crash-2026-08-24_11.04.12-server.txt
crash: crash-2026-08-24_11.04.12-server.txt
crash reports 1
crashed at 2026-08-24 11:04:12
minecraft 1.21.4
server brand Paper
java 21.0.5, Eclipse Adoptium
memory 1476395008 bytes (1408 MiB) / 3221225472 bytes (3072 MiB) up to 3221225472 bytes (3072 MiB)
plugins listed 3
jvm flags read from crash-2026-08-24_11.04.12-server.txt
[critical] X001 The server crashed
crash-2026-08-24_11.04.12-server.txt
Ticking entity, at 2026-08-24 11:04:12. The trace starts in com.example.spawner.SpawnerListener.follow.
java.lang.NullPointerException: Cannot invoke "org.bukkit.entity.Player.getLocation()" because "target" is null
at com.example.spawner.SpawnerListener.follow(SpawnerListener.java:118)
at com.example.spawner.SpawnerTask.tick(SpawnerTask.java:42)
at net.minecraft.world.entity.Mob.customServerAiStep(Mob.java:322)
fix: Work from the frame this names rather than from the last console line before the stop.
[critical] X002 A plugin's code is in the crash trace
crash-2026-08-24_11.04.12-server.txt
The outermost frame that is not server, JDK or Netty code belongs to com.example.spawner.
at com.example.spawner.SpawnerListener.follow(SpawnerListener.java:118)
fix: Reproduce with that plugin removed. If the crash stops, report it upstream with this file.
[warning] X004 The crash happened while ticking one specific object
crash-2026-08-24_11.04.12-server.txt
The report says the server was ticking one object: Entity being ticked.
Entity Type: minecraft:zombie (net.minecraft.world.entity.monster.Zombie)
Entity's Exact location: -184.50, 63.00, 1042.31
fix: Note the coordinates from the section below and remove or repair that object before the chunk is loaded again.
Summary: 2 criticals, 1 warning
------------------------------------------------------------------------
flags: crash-2026-08-24_11.04.12-server.txt (JVM Flags)
heap -Xms 1.0 GiB, -Xmx 3.0 GiB
collector -XX:+UseConcMarkSweepGC
-XX flags 1
[critical] F003 The watchdog is switched off
crash-2026-08-24_11.04.12-server.txt (JVM Flags)
-Ddisable.watchdog is set, so Paper will not dump threads or stop a hung server.
fix: Remove -Ddisable.watchdog and fix the hang it was hiding.
[warning] F006 Flags for a collector that no longer exists
crash-2026-08-24_11.04.12-server.txt (JVM Flags)
CMS-era flags present: UseConcMarkSweepGC.
fix: Delete them and use the documented G1 set instead.
[two more findings omitted here: -Xms differs from -Xmx, and the heap is under Paper's
documented floor]
Summary: 1 critical, 3 warnings
Run the same command against crash-reports/ and a fourth finding appears that no single file
contains: the same description written twice, half an hour apart, which is a restart loop rather than
an accident.
Stated plainly, because a tool that reports nothing looks the same as a tool that found nothing.
- It reads
level.datand, for each region file, the 8 KiB header that says where its chunks are. It does not decompress or decode a single chunk, entity, block or player file, so it can tell you a region file has lost the sectors its header points at and never what was in them. - It writes nothing. Every audit opens files read-only, and the only file it creates is the report you
ask for with
-o. - It does not open a network connection unless you pass
--online, and then only to identify plugin jars: by digest on Modrinth, and by project name on Hangar when a digest is not enough. - It does not judge tuning by taste. There is no rule saying your view distance is wrong, because the hardware it has to be right for is not in the folder.
- It cannot verify a self-declaration.
folia-supported,api-version, a plugin's own version — all of it is what the author wrote. Only the file hash identifies a build. - Log and crash attribution is best effort and says which method it used. A shaded or relocated plugin
can put a library's package in the frame you would otherwise blame, and the bracketed name in a log
line is the descriptor's
prefix:when one is set, which need not resemble the plugin's name. difftakes no side. It reports that two servers differ, never which one is right.- It does not read a timings or spark report. Tick-time analysis is a different problem and is not pretended at here.
plugins --onlinematching nothing is not a safety finding. Paid plugins, private builds and pruned releases all fail to match for ordinary reasons. A Hangar answer is weaker than a Modrinth one by construction — Hangar has no digest endpoint, so it has to be asked by name — and the findings say which one answered.
serverdoctor mcp speaks the Model Context Protocol over stdio, exposing triage_log,
audit_config, audit_jvm_flags, audit_plugins, audit_worlds, read_crash_reports,
audit_access_lists, diff_servers and list_rules. All nine are read-only and return the JSON
report. For a client that reads the usual config file:
{
"mcpServers": {
"serverdoctor": {
"command": "java",
"args": ["-jar", "/opt/serverdoctor/serverdoctor.jar", "mcp"]
}
}
}The agent gets the same rule ids and the same sources, so when it tells someone their server is hanging on MySQL it can point at the line that says so.
$ java -jar serverdoctor.jar rules --area flags
F003 [critical] The watchdog is switched off
why: disable.watchdog stops Paper from dumping threads and ending a hung server. A
deadlock then presents as a server that is up, accepts connections and never ticks,
and no diagnosis is written to the log.
fix: Remove -Ddisable.watchdog and fix the hang it was hiding.
source: PaperMC/Paper paper-server/src/main/java/org/spigotmc/WatchdogThread.java:16
Ninety-one rules across the eight audits. Every source: line names a file and line in Paper or
Folia, a JEP, an API response that was checked, or the file on disk the fact was read from, and the
whole catalogue is in docs/rules.md.
./gradlew build
Kotlin 2.4.10 on a JDK 21 toolchain, Gradle 9.7.1 through the wrapper. build/libs/serverdoctor-<version>.jar
is the self-contained CLI; the -thin classifier is the unshaded jar and is not the one to run. The
test suite works against the fixtures in src/test/resources, which are deliberately broken servers
and logs rather than mocks.
serverdoctor is an independent project. It is not affiliated with, endorsed by or sponsored by PaperMC, Mojang, Microsoft or Modrinth.
No code was copied from any other project. Several solved a neighbouring problem first and are
credited in NOTICE alongside the sources every rule's underlying fact was read from —
including aternosorg/mclogs, whose hosted analyser is the
closest existing thing to the log command, birdflop/botflop,
which answers configuration questions from timings reports, and
YouHaveTrouble/minecraft-optimization,
the guide most of this ecosystem's tuning advice traces back to.
Two earlier tools by the same author cover the build side of the same problem: foliascan reports where a plugin jar breaks on Folia, and pluginscope audits jar packaging, API compatibility and resource packs.
Apache License 2.0. See LICENSE.