vitaminmcp 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # vitaminmcp
2
+
3
+ **MCP server for testing Minecraft plugins.** Drives a real Paper/Purpur server and real protocol
4
+ bots from an AI agent, so a plugin can be tested end to end without opening the game.
5
+
6
+ This package is the launcher. It fetches the jars it needs on first run and speaks stdio to your
7
+ MCP client — it is not the whole product on its own: the agent is a Paper plugin, and it goes on
8
+ the Minecraft server.
9
+
10
+ ```bash
11
+ claude mcp add vitaminmcp -- npx -y vitaminmcp
12
+ ```
13
+
14
+ Or in `.mcp.json`, `claude_desktop_config.json`, or whatever your client calls it:
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "vitaminmcp": {
20
+ "command": "npx",
21
+ "args": ["-y", "vitaminmcp"]
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ Then, in Claude Code, `/mcp__vitaminmcp__setup` walks through the other half — putting
28
+ `VitaminMCP.jar` in the server's `plugins/`, restarting it, and connecting. Or just ask:
29
+
30
+ > **Prompt:** Set up VitaminMCP on my Minecraft server at ~/servers/test and connect to it.
31
+
32
+ Once the plugin is running, `session_start` needs no arguments for a server on this machine: the
33
+ agent leaves its host, ports and token where this server reads them.
34
+
35
+ ## What you get
36
+
37
+ - Spawn and control test players — real protocol clients, not mock `Player` objects
38
+ - Execute commands as the console or as a player
39
+ - Open, read, click and assert on inventories and plugin GUIs
40
+ - Wait for events and conditions instead of sleeping
41
+ - Read live server state: events, logs, exceptions, permissions
42
+ - Paper / Purpur 1.21 through 1.21.8, from one install
43
+
44
+ ## Requires
45
+
46
+ - **Java 21 or later** on this machine — the jars run on the JVM. Point `JAVA_HOME` at it, or have
47
+ `java` on `PATH`
48
+ - **Paper 1.21 or later** on the Minecraft server, with `VitaminMCP.jar` in its `plugins/`
49
+
50
+ ## Environment
51
+
52
+ | | |
53
+ |---|---|
54
+ | `JAVA_HOME` | the JDK to run the jars with |
55
+ | `VITAMINMCP_HOME` | where jars and agent handshakes are kept. Default `~/.vitaminmcp` |
56
+ | `VITAMINMCP_TOKEN` | an agent token, for a server that leaves no local handshake |
57
+ | `VITAMINMCP_SERVER_JAR` | run this `mcp-server.jar` instead of a downloaded one |
58
+ | `VITAMINMCP_RUNNER_JAR` | use this bot runner instead of a downloaded one |
59
+
60
+ ## What it downloads
61
+
62
+ On first run, from [the GitHub release](https://github.com/Backas03/VitaminMCP-minecraft/releases)
63
+ matching this package's version, into `~/.vitaminmcp/jars/<version>/`:
64
+
65
+ - `mcp-server.jar` (~2 MB) — waited for, since nothing works without it
66
+ - `bot-runner.jar` (~93 MB) — fetched in the background, because only bots need it. A client that
67
+ never spawns one never waits for it
68
+
69
+ Both are checked against a SHA-256 pinned into this package at publish time. A file that does not
70
+ match is deleted rather than run.
71
+
72
+ Full documentation, design notes and the plugin itself:
73
+ **[github.com/Backas03/VitaminMCP-minecraft](https://github.com/Backas03/VitaminMCP-minecraft)**
74
+
75
+ MIT.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import { BOT_RUNNER_JAR, MCP_SERVER_JAR, ensureJar, jarPath } from '../lib/jars.mjs';
9
+ import { checkJava, findJava } from '../lib/java.mjs';
10
+
11
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ /**
14
+ * stdout is the MCP channel and carries nothing but JSON-RPC. Everything this launcher has to say
15
+ * goes to stderr, where a client shows it as server output rather than trying to parse it.
16
+ */
17
+ function say(message) {
18
+ process.stderr.write(`[vitaminmcp] ${message}\n`);
19
+ }
20
+
21
+ function die(message) {
22
+ process.stderr.write(`[vitaminmcp] ${message}\n`);
23
+ process.exit(1);
24
+ }
25
+
26
+ async function version() {
27
+ const manifest = await fs.readFile(path.join(HERE, '..', 'package.json'), 'utf8');
28
+ return JSON.parse(manifest).version;
29
+ }
30
+
31
+ const HELP = `vitaminmcp — MCP server for testing Minecraft plugins
32
+
33
+ Launched by an MCP client, not usually by hand. To connect it to Claude Code:
34
+
35
+ claude mcp add vitaminmcp -- npx -y vitaminmcp
36
+
37
+ The agent plugin still has to be installed on the Minecraft server itself. Once this server is
38
+ connected, the /mcp__vitaminmcp__setup command walks through it.
39
+
40
+ Options
41
+ --help this text
42
+ --version the version this launcher will run
43
+ --jars download the jars and print where they are, without starting anything
44
+
45
+ Environment
46
+ JAVA_HOME the JDK to run the jars with; Java 21 or later
47
+ VITAMINMCP_HOME where jars and agent handshakes are kept (default ~/.vitaminmcp)
48
+ VITAMINMCP_TOKEN an agent token, for a server that leaves no local handshake
49
+ VITAMINMCP_SERVER_JAR run this mcp-server jar instead of a downloaded one
50
+ VITAMINMCP_RUNNER_JAR use this bot runner instead of a downloaded one
51
+ `;
52
+
53
+ async function main() {
54
+ const argv = process.argv.slice(2);
55
+ const release = await version();
56
+
57
+ if (argv.includes('--help') || argv.includes('-h')) {
58
+ process.stderr.write(HELP);
59
+ return 0;
60
+ }
61
+ if (argv.includes('--version') || argv.includes('-v')) {
62
+ process.stderr.write(`${release}\n`);
63
+ return 0;
64
+ }
65
+
66
+ const java = findJava();
67
+ const usable = checkJava(java);
68
+ if (!usable.ok) {
69
+ die(usable.message);
70
+ }
71
+
72
+ // The server jar is small and nothing works without it, so it is waited for. The runner is
73
+ // ninety megabytes and only bots need it, so it is fetched alongside: a client that never
74
+ // spawns a bot never waits for it, and one that does waits inside a tool call rather than
75
+ // inside a startup timeout.
76
+ let server = process.env.VITAMINMCP_SERVER_JAR;
77
+ if (!server) {
78
+ try {
79
+ server = await ensureJar(release, MCP_SERVER_JAR, { log: say });
80
+ } catch (error) {
81
+ die(String(error.message ?? error));
82
+ }
83
+ }
84
+
85
+ const runner = process.env.VITAMINMCP_RUNNER_JAR ?? jarPath(release, BOT_RUNNER_JAR);
86
+ const runnerReady = process.env.VITAMINMCP_RUNNER_JAR
87
+ ? Promise.resolve(runner)
88
+ : ensureJar(release, BOT_RUNNER_JAR, { log: say }).catch((error) => {
89
+ say(`the bot runner could not be downloaded: ${error.message ?? error}`);
90
+ say('Everything except bots still works. Retry by restarting this server.');
91
+ });
92
+
93
+ if (argv.includes('--jars')) {
94
+ await runnerReady;
95
+ process.stderr.write(`${server}\n${runner}\n`);
96
+ return 0;
97
+ }
98
+
99
+ return await run(java, server, runner);
100
+ }
101
+
102
+ /**
103
+ * Runs the server jar, and lives exactly as long as it does.
104
+ *
105
+ * A download still in flight is abandoned when the server exits rather than held on to: the
106
+ * `.part` file it leaves is claimed again by the next start, and a client waiting on a process
107
+ * that no longer serves anything is worse than a jar fetched twice.
108
+ */
109
+ function run(java, server, runner) {
110
+ const child = spawn(java, ['-jar', server], {
111
+ stdio: 'inherit',
112
+ env: { ...process.env, VITAMINMCP_RUNNER_JAR: runner },
113
+ });
114
+
115
+ return new Promise((resolve) => {
116
+ for (const signal of ['SIGINT', 'SIGTERM']) {
117
+ process.on(signal, () => child.kill(signal));
118
+ }
119
+
120
+ child.on('error', (error) => {
121
+ say(`could not start java: ${error.message}`);
122
+ resolve(1);
123
+ });
124
+
125
+ child.on('exit', (code, signal) => {
126
+ resolve(signal ? 1 : (code ?? 0));
127
+ });
128
+ });
129
+ }
130
+
131
+ main().then(
132
+ (code) => process.exit(code),
133
+ (error) => die(String(error?.stack ?? error)),
134
+ );
package/checksums.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": "1.5.0",
3
+ "jars": {
4
+ "mcp-server.jar": "edf2c4e468751e38c7b31f30148a28c417f16762e5519643fdf287f789a9a5f5",
5
+ "bot-runner.jar": "f681c8049f2ee6b194b1ae5bb07a5eedbce89f816aa89eac0b713e5adbb207b9"
6
+ }
7
+ }
package/lib/jars.mjs ADDED
@@ -0,0 +1,148 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { Readable } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ const RELEASES = 'https://github.com/Backas03/VitaminMCP-minecraft/releases/download';
13
+
14
+ /** The jar this package launches, and the one it launches in turn. */
15
+ export const MCP_SERVER_JAR = 'mcp-server.jar';
16
+ export const BOT_RUNNER_JAR = 'bot-runner.jar';
17
+
18
+ let checksumsPromise;
19
+
20
+ const REPORT = 'https://github.com/Backas03/VitaminMCP-minecraft/issues';
21
+
22
+ /**
23
+ * The exact bytes this version of the package expects, stamped in at release.
24
+ *
25
+ * Pinning them here rather than trusting whatever the release currently serves is the point: a
26
+ * release asset can be replaced after the fact, and a published npm version cannot.
27
+ *
28
+ * The file names the version it was stamped for, and that is checked. Otherwise the one mistake
29
+ * this design invites — publishing a version bump without re-stamping — would ship a package that
30
+ * downloads one release and checks it against another, and every install would fail on a hash
31
+ * mismatch that reads exactly like a compromised download.
32
+ */
33
+ async function checksums(version) {
34
+ checksumsPromise ??= fs
35
+ .readFile(path.join(HERE, '..', 'checksums.json'), 'utf8')
36
+ .then(JSON.parse)
37
+ .catch((cause) => {
38
+ throw new Error(
39
+ 'This package was published without checksums.json, so it cannot verify what it ' +
40
+ `downloads and will not download anything. Please report it at ${REPORT}.`,
41
+ { cause },
42
+ );
43
+ });
44
+
45
+ const stamped = await checksumsPromise;
46
+ if (stamped.version !== version) {
47
+ throw new Error(
48
+ `This package is version ${version} but its checksums were stamped for ` +
49
+ `${stamped.version}, so it cannot tell a bad download from a mismatched one. Nothing ` +
50
+ `was installed. Please report it at ${REPORT}.`,
51
+ );
52
+ }
53
+ return stamped.jars ?? {};
54
+ }
55
+
56
+ /**
57
+ * Where downloaded jars live: one directory per version, so switching versions never mixes them.
58
+ *
59
+ * The same root the agent writes its handshake into, so one variable moves everything VitaminMCP
60
+ * keeps outside the project.
61
+ */
62
+ export function cacheDirectory(version) {
63
+ const root = process.env.VITAMINMCP_HOME || path.join(os.homedir(), '.vitaminmcp');
64
+ return path.join(root, 'jars', version);
65
+ }
66
+
67
+ /** A jar already on this machine, or null. Never downloads. */
68
+ export async function cachedJar(version, name) {
69
+ const file = path.join(cacheDirectory(version), name);
70
+ try {
71
+ await fs.access(file);
72
+ return file;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Makes sure one jar is on this machine, downloading it if not, and returns its path.
80
+ *
81
+ * Downloads land on a `.part` file and are renamed once the hash matches, so an interrupted
82
+ * download is never mistaken for a finished one — and the server, which waits for exactly that
83
+ * rename, never opens a half-written jar.
84
+ */
85
+ export async function ensureJar(version, name, { log = () => {} } = {}) {
86
+ const directory = cacheDirectory(version);
87
+ const file = path.join(directory, name);
88
+
89
+ const existing = await cachedJar(version, name);
90
+ if (existing) {
91
+ return existing;
92
+ }
93
+
94
+ const expected = (await checksums(version))[name];
95
+ if (!expected) {
96
+ throw new Error(`checksums.json does not cover ${name}, so it will not be downloaded.`);
97
+ }
98
+
99
+ await fs.mkdir(directory, { recursive: true });
100
+ const partial = `${file}.part`;
101
+ const url = `${RELEASES}/${version}/${name}`;
102
+
103
+ // Claimed before the request goes out, not after it answers. The server waits on this file to
104
+ // decide whether a jar is coming or simply absent, and between spawning it and the first byte
105
+ // arriving there is easily enough time for it to ask.
106
+ await fs.writeFile(partial, '');
107
+
108
+ log(`downloading ${name} (${version})`);
109
+
110
+ const response = await fetch(url, { redirect: 'follow' });
111
+ if (!response.ok || !response.body) {
112
+ throw new Error(
113
+ `Could not download ${name}: ${response.status} ${response.statusText}\n ${url}`,
114
+ );
115
+ }
116
+
117
+ const hash = createHash('sha256');
118
+ const source = Readable.fromWeb(response.body);
119
+ source.on('data', (chunk) => hash.update(chunk));
120
+
121
+ try {
122
+ await pipeline(source, createWriteStream(partial));
123
+
124
+ const actual = hash.digest('hex');
125
+ if (actual !== expected) {
126
+ throw new Error(
127
+ `${name} does not match the checksum this package was published with.\n` +
128
+ ` expected ${expected}\n` +
129
+ ` received ${actual}\n` +
130
+ ` from ${url}\n` +
131
+ 'Nothing was installed. Please report this.',
132
+ );
133
+ }
134
+
135
+ await fs.rename(partial, file);
136
+ } catch (error) {
137
+ await fs.rm(partial, { force: true });
138
+ throw error;
139
+ }
140
+
141
+ log(`${name} ready`);
142
+ return file;
143
+ }
144
+
145
+ /** Where a jar will be once it has downloaded, whether or not it has. */
146
+ export function jarPath(version, name) {
147
+ return path.join(cacheDirectory(version), name);
148
+ }
package/lib/java.mjs ADDED
@@ -0,0 +1,67 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /** The floor the jars are compiled against. Below it they will not load at all. */
6
+ export const REQUIRED_JAVA = 21;
7
+
8
+ /**
9
+ * The java to run the jars with: JAVA_HOME if it points at one, otherwise whatever is on PATH.
10
+ *
11
+ * JAVA_HOME wins because a machine with several JDKs usually means one of them was chosen
12
+ * deliberately, and PATH is the one nobody remembers setting.
13
+ */
14
+ export function findJava() {
15
+ const home = process.env.JAVA_HOME;
16
+ if (home) {
17
+ const candidate = path.join(home, 'bin', process.platform === 'win32' ? 'java.exe' : 'java');
18
+ if (existsSync(candidate)) {
19
+ return candidate;
20
+ }
21
+ }
22
+ return process.platform === 'win32' ? 'java.exe' : 'java';
23
+ }
24
+
25
+ /**
26
+ * Checks the java that will actually be used, and says what is wrong in terms of a fix.
27
+ *
28
+ * Worth doing before spawning: java's own failure for a too-old runtime is
29
+ * `UnsupportedClassVersionError` naming class file version 65, which is a puzzle rather than a
30
+ * message, and an MCP client shows it — if it shows anything — as a server that died at startup.
31
+ */
32
+ export function checkJava(java) {
33
+ const probe = spawnSync(java, ['-version'], { encoding: 'utf8' });
34
+
35
+ if (probe.error) {
36
+ return {
37
+ ok: false,
38
+ message:
39
+ `No Java found. VitaminMCP runs on the JVM, so it needs Java ${REQUIRED_JAVA} or later ` +
40
+ `on this machine.\n` +
41
+ ` Tried: ${java}\n` +
42
+ ` Install a JDK (https://adoptium.net/), or point JAVA_HOME at one you already have.`,
43
+ };
44
+ }
45
+
46
+ // Every JVM prints its version to stderr, and has since before anyone thought to standardise it.
47
+ const output = `${probe.stderr || ''}${probe.stdout || ''}`;
48
+ const match = output.match(/version "(\d+)(?:\.(\d+))?/);
49
+ if (!match) {
50
+ // An unrecognised banner is not a reason to refuse; the jars themselves will say so if it
51
+ // really is too old.
52
+ return { ok: true, version: null };
53
+ }
54
+
55
+ const major = match[1] === '1' ? Number(match[2]) : Number(match[1]);
56
+ if (major < REQUIRED_JAVA) {
57
+ return {
58
+ ok: false,
59
+ version: major,
60
+ message:
61
+ `Java ${major} is too old — VitaminMCP needs ${REQUIRED_JAVA} or later.\n` +
62
+ ` Using: ${java}\n` +
63
+ ` Install a newer JDK (https://adoptium.net/), or point JAVA_HOME at one you already have.`,
64
+ };
65
+ }
66
+ return { ok: true, version: major };
67
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "vitaminmcp",
3
+ "version": "1.5.0",
4
+ "mcpName": "io.github.backas03/vitaminmcp",
5
+ "description": "MCP server for testing Minecraft plugins: drives a real Paper server and real protocol bots from an AI agent.",
6
+ "keywords": [
7
+ "mcp",
8
+ "modelcontextprotocol",
9
+ "minecraft",
10
+ "paper",
11
+ "purpur",
12
+ "bukkit",
13
+ "plugin-testing",
14
+ "end-to-end-testing"
15
+ ],
16
+ "homepage": "https://github.com/Backas03/VitaminMCP-minecraft#readme",
17
+ "bugs": "https://github.com/Backas03/VitaminMCP-minecraft/issues",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Backas03/VitaminMCP-minecraft.git",
21
+ "directory": "npm"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Backas03",
25
+ "type": "module",
26
+ "bin": {
27
+ "vitaminmcp": "bin/vitaminmcp.mjs"
28
+ },
29
+ "engines": {
30
+ "node": ">=18.17"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "lib",
35
+ "checksums.json",
36
+ "README.md"
37
+ ],
38
+ "scripts": {
39
+ "stamp": "node scripts/stamp-checksums.mjs",
40
+ "prepublishOnly": "node scripts/stamp-checksums.mjs --verify"
41
+ }
42
+ }