vitaminmcp 1.5.0 → 2.0.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 CHANGED
@@ -45,6 +45,8 @@ agent leaves its host, ports and token where this server reads them.
45
45
 
46
46
  - **Java 21 or later** on this machine — the jars run on the JVM. Point `JAVA_HOME` at it, or have
47
47
  `java` on `PATH`
48
+ - **Node 18.17 or later** when using the source runner fallback. If Node is absent, the launcher
49
+ selects a pinned platform runner asset instead.
48
50
  - **Paper 1.21 or later** on the Minecraft server, with `VitaminMCP.jar` in its `plugins/`
49
51
 
50
52
  ## Environment
@@ -55,7 +57,9 @@ agent leaves its host, ports and token where this server reads them.
55
57
  | `VITAMINMCP_HOME` | where jars and agent handshakes are kept. Default `~/.vitaminmcp` |
56
58
  | `VITAMINMCP_TOKEN` | an agent token, for a server that leaves no local handshake |
57
59
  | `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 |
60
+ | `VITAMINMCP_RUNNER_JAR` | use this runner path instead of automatic selection |
61
+ | `VITAMINMCP_NODE` | Node executable for the source runner fallback |
62
+ | `VITAMINMCP_NODE_RUNNER` | bundled `runner.mjs` path when Node is available |
59
63
 
60
64
  ## What it downloads
61
65
 
@@ -63,8 +67,9 @@ On first run, from [the GitHub release](https://github.com/Backas03/VitaminMCP-m
63
67
  matching this package's version, into `~/.vitaminmcp/jars/<version>/`:
64
68
 
65
69
  - `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
70
+ - a source Node runner when Node and the bundled runner are available no runner asset download
71
+ - otherwise one platform runner asset (`win-x64`, `linux-x64`, `linux-arm64`, `darwin-x64` or
72
+ `darwin-arm64`), checked against a SHA-256 pinned into this package
68
73
 
69
74
  Both are checked against a SHA-256 pinned into this package at publish time. A file that does not
70
75
  match is deleted rather than run.
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
4
5
  import fs from 'node:fs/promises';
5
6
  import path from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
 
8
- import { BOT_RUNNER_JAR, MCP_SERVER_JAR, ensureJar, jarPath } from '../lib/jars.mjs';
9
+ import {
10
+ MCP_SERVER_JAR, assetPath, ensureAsset, ensureJar,
11
+ } from '../lib/jars.mjs';
9
12
  import { checkJava, findJava } from '../lib/java.mjs';
13
+ import { checkNode, findNode, runnerAssetName } from '../lib/node.mjs';
10
14
 
11
15
  const HERE = path.dirname(fileURLToPath(import.meta.url));
12
16
 
@@ -47,7 +51,9 @@ Environment
47
51
  VITAMINMCP_HOME where jars and agent handshakes are kept (default ~/.vitaminmcp)
48
52
  VITAMINMCP_TOKEN an agent token, for a server that leaves no local handshake
49
53
  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
54
+ VITAMINMCP_RUNNER_JAR use this runner path instead of selecting one automatically
55
+ VITAMINMCP_NODE Node executable for the source runner fallback
56
+ VITAMINMCP_NODE_RUNNER path to a bundled runner.mjs when Node is available
51
57
  `;
52
58
 
53
59
  async function main() {
@@ -82,13 +88,24 @@ async function main() {
82
88
  }
83
89
  }
84
90
 
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
- });
91
+ const configuredRunner = process.env.VITAMINMCP_RUNNER_JAR;
92
+ const node = findNode();
93
+ const nodeCheck = checkNode(node);
94
+ const sourceRunner = process.env.VITAMINMCP_NODE_RUNNER
95
+ ?? path.join(HERE, '..', 'runner', 'runner.mjs');
96
+ let runner;
97
+ let runnerReady;
98
+ if (configuredRunner) {
99
+ runner = configuredRunner;
100
+ runnerReady = Promise.resolve(runner);
101
+ } else if (nodeCheck.ok && existsSync(sourceRunner)) {
102
+ runner = sourceRunner;
103
+ runnerReady = Promise.resolve(runner);
104
+ } else {
105
+ const asset = runnerAssetName();
106
+ runner = assetPath(release, asset);
107
+ runnerReady = ensureAsset(release, asset, { log: say });
108
+ }
92
109
 
93
110
  if (argv.includes('--jars')) {
94
111
  await runnerReady;
@@ -96,6 +113,7 @@ async function main() {
96
113
  return 0;
97
114
  }
98
115
 
116
+ await runnerReady;
99
117
  return await run(java, server, runner);
100
118
  }
101
119
 
package/checksums.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
- "version": "1.5.0",
2
+ "version": "2.0.0",
3
3
  "jars": {
4
- "mcp-server.jar": "edf2c4e468751e38c7b31f30148a28c417f16762e5519643fdf287f789a9a5f5",
5
- "bot-runner.jar": "f681c8049f2ee6b194b1ae5bb07a5eedbce89f816aa89eac0b713e5adbb207b9"
4
+ "mcp-server.jar": "557ad3ad919244e31fcbc8f7ecb05c8c7744c2922a0220e0fec9eb531af77759"
5
+ },
6
+ "assets": {
7
+ "bot-runner-win-x64.exe": "230ea83146c9f3ade0999764e8b9dd5ca790f13fec055484237ba8e6b7d8b3bd"
6
8
  }
7
9
  }
package/lib/jars.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { createWriteStream } from 'node:fs';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
3
  import fs from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
@@ -13,9 +13,9 @@ const RELEASES = 'https://github.com/Backas03/VitaminMCP-minecraft/releases/down
13
13
 
14
14
  /** The jar this package launches, and the one it launches in turn. */
15
15
  export const MCP_SERVER_JAR = 'mcp-server.jar';
16
- export const BOT_RUNNER_JAR = 'bot-runner.jar';
17
16
 
18
17
  let checksumsPromise;
18
+ let manifestPromise;
19
19
 
20
20
  const REPORT = 'https://github.com/Backas03/VitaminMCP-minecraft/issues';
21
21
 
@@ -53,6 +53,17 @@ async function checksums(version) {
53
53
  return stamped.jars ?? {};
54
54
  }
55
55
 
56
+ async function assetChecksums(version) {
57
+ manifestPromise ??= fs
58
+ .readFile(path.join(HERE, '..', 'checksums.json'), 'utf8')
59
+ .then(JSON.parse);
60
+ const stamped = await manifestPromise;
61
+ if (stamped.version !== version) {
62
+ throw new Error(`This package is version ${version} but its checksums were stamped for ${stamped.version}.`);
63
+ }
64
+ return stamped.assets ?? {};
65
+ }
66
+
56
67
  /**
57
68
  * Where downloaded jars live: one directory per version, so switching versions never mixes them.
58
69
  *
@@ -69,10 +80,14 @@ export async function cachedJar(version, name) {
69
80
  const file = path.join(cacheDirectory(version), name);
70
81
  try {
71
82
  await fs.access(file);
72
- return file;
73
83
  } catch {
74
84
  return null;
75
85
  }
86
+ const expected = (await checksums(version))[name];
87
+ if (!expected) return null;
88
+ if (await verifyFileHash(file, expected)) return file;
89
+ await fs.rm(file, { force: true });
90
+ return null;
76
91
  }
77
92
 
78
93
  /**
@@ -146,3 +161,76 @@ export async function ensureJar(version, name, { log = () => {} } = {}) {
146
161
  export function jarPath(version, name) {
147
162
  return path.join(cacheDirectory(version), name);
148
163
  }
164
+
165
+ export function assetCacheDirectory(version) {
166
+ const root = process.env.VITAMINMCP_HOME || path.join(os.homedir(), '.vitaminmcp');
167
+ return path.join(root, 'assets', version);
168
+ }
169
+
170
+ export async function cachedAsset(version, name) {
171
+ const file = path.join(assetCacheDirectory(version), name);
172
+ try {
173
+ await fs.access(file);
174
+ } catch {
175
+ return null;
176
+ }
177
+ const expected = (await assetChecksums(version))[name];
178
+ if (!expected) return null;
179
+ if (await verifyFileHash(file, expected)) return file;
180
+ await fs.rm(file, { force: true });
181
+ return null;
182
+ }
183
+
184
+ /** Fetches a pinned platform asset only after Node fallback selection has failed. */
185
+ export async function ensureAsset(version, name, { log = () => {} } = {}) {
186
+ const directory = assetCacheDirectory(version);
187
+ const file = path.join(directory, name);
188
+ const existing = await cachedAsset(version, name);
189
+ if (existing) return existing;
190
+ const expected = (await assetChecksums(version))[name];
191
+ if (!expected) throw new Error(`checksums.json does not cover asset ${name}, so it will not be downloaded.`);
192
+ await fs.mkdir(directory, { recursive: true });
193
+ const partial = `${file}.part`;
194
+ await fs.writeFile(partial, '');
195
+ const url = `${RELEASES}/${version}/${name}`;
196
+ log(`downloading ${name} (${version})`);
197
+ const response = await fetch(url, { redirect: 'follow' });
198
+ if (!response.ok || !response.body) {
199
+ await fs.rm(partial, { force: true });
200
+ throw new Error(`Could not download ${name}: ${response.status} ${response.statusText}\n ${url}`);
201
+ }
202
+ const hash = createHash('sha256');
203
+ const source = Readable.fromWeb(response.body);
204
+ source.on('data', (chunk) => hash.update(chunk));
205
+ try {
206
+ await pipeline(source, createWriteStream(partial));
207
+ const actual = hash.digest('hex');
208
+ if (actual !== expected) {
209
+ throw new Error(`${name} does not match its pinned checksum.\n expected ${expected}\n received ${actual}`);
210
+ }
211
+ await fs.rename(partial, file);
212
+ if (process.platform !== 'win32') await fs.chmod(file, 0o755);
213
+ } catch (error) {
214
+ await fs.rm(partial, { force: true });
215
+ throw error;
216
+ }
217
+ log(`${name} ready`);
218
+ return file;
219
+ }
220
+
221
+ export function assetPath(version, name) {
222
+ return path.join(assetCacheDirectory(version), name);
223
+ }
224
+
225
+ async function sha256File(file) {
226
+ const hash = createHash('sha256');
227
+ for await (const chunk of createReadStream(file)) {
228
+ hash.update(chunk);
229
+ }
230
+ return hash.digest('hex');
231
+ }
232
+
233
+ /** Verifies a cached release byte-for-byte without loading the whole file into memory. */
234
+ export async function verifyFileHash(file, expected) {
235
+ return (await sha256File(file)) === expected;
236
+ }
package/lib/node.mjs ADDED
@@ -0,0 +1,43 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export const REQUIRED_NODE = '18.17';
6
+
7
+ /** Finds an explicitly configured Node first, then the executable on PATH. */
8
+ export function findNode() {
9
+ const configured = process.env.VITAMINMCP_NODE;
10
+ if (configured && (existsSync(configured) || path.basename(configured) === configured)) {
11
+ return configured;
12
+ }
13
+ return process.platform === 'win32' ? 'node.exe' : 'node';
14
+ }
15
+
16
+ /** Checks Node before choosing the source runner path. */
17
+ export function checkNode(node) {
18
+ const probe = spawnSync(node, ['--version'], { encoding: 'utf8' });
19
+ if (probe.error) {
20
+ return { ok: false, message: `No Node found. The hybrid runner needs Node ${REQUIRED_NODE} or later.` };
21
+ }
22
+ const match = `${probe.stdout || ''}${probe.stderr || ''}`.match(/v(\d+)(?:\.(\d+))?/);
23
+ if (!match) return { ok: true, version: null };
24
+ const major = Number(match[1]);
25
+ const minor = Number(match[2] ?? 0);
26
+ if (major < 18 || (major === 18 && minor < 17)) {
27
+ return { ok: false, version: `${major}.${minor}`, message:
28
+ `Node ${major}.${minor} is too old — the hybrid runner needs Node ${REQUIRED_NODE} or later.` };
29
+ }
30
+ return { ok: true, version: `${major}.${minor}` };
31
+ }
32
+
33
+ /** The release asset name for the current SEA target. */
34
+ export function runnerAssetName(platform = process.platform, arch = process.arch) {
35
+ if (platform === 'win32' && arch === 'x64') return 'bot-runner-win-x64.exe';
36
+ if (platform === 'linux' || platform === 'darwin') {
37
+ throw new Error(
38
+ `Native runner assets for ${platform}-${arch} are planned but not released yet. `
39
+ + 'Install Node 18.17 or later to use the source runner.',
40
+ );
41
+ }
42
+ throw new Error(`No VitaminMCP runner asset exists for ${platform}-${arch}.`);
43
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vitaminmcp",
3
- "version": "1.5.0",
4
- "mcpName": "io.github.backas03/vitaminmcp",
3
+ "version": "2.0.0",
4
+ "mcpName": "io.github.Backas03/vitaminmcp",
5
5
  "description": "MCP server for testing Minecraft plugins: drives a real Paper server and real protocol bots from an AI agent.",
6
6
  "keywords": [
7
7
  "mcp",
@@ -23,6 +23,10 @@
23
23
  "license": "MIT",
24
24
  "author": "Backas03",
25
25
  "type": "module",
26
+ "dependencies": {
27
+ "mineflayer": "^4.37.1",
28
+ "mineflayer-pathfinder": "2.4.5"
29
+ },
26
30
  "bin": {
27
31
  "vitaminmcp": "bin/vitaminmcp.mjs"
28
32
  },
@@ -32,11 +36,14 @@
32
36
  "files": [
33
37
  "bin",
34
38
  "lib",
39
+ "runner",
35
40
  "checksums.json",
36
41
  "README.md"
37
42
  ],
38
43
  "scripts": {
44
+ "test": "node --test test/*.test.mjs",
39
45
  "stamp": "node scripts/stamp-checksums.mjs",
40
- "prepublishOnly": "node scripts/stamp-checksums.mjs --verify"
46
+ "stage-runner": "node scripts/stage-node-runner.mjs",
47
+ "prepublishOnly": "node scripts/stage-node-runner.mjs && node scripts/stamp-checksums.mjs --verify"
41
48
  }
42
49
  }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The bot runner, on mineflayer.
4
+ *
5
+ * Speaks the VitaminMCP stdio line protocol and is launched as the runner child process:
6
+ *
7
+ * node runner.mjs <host> <port> [protocol]
8
+ *
9
+ * Everything it prints on stdout is protocol. Diagnostics go to stderr, which the MCP server
10
+ * inherits — a stray `console.log` here is a desynchronised session, not a stray log line.
11
+ */
12
+ import { createInterface } from 'node:readline';
13
+
14
+ import { BotRegistry } from './src/bots.mjs';
15
+ import { Dispatch } from './src/dispatch.mjs';
16
+ import * as protocol from './src/protocol.mjs';
17
+ import { pingProtocol, versionForProtocol } from './src/version.mjs';
18
+
19
+ async function main() {
20
+ const argv = process.argv.slice(2);
21
+ if (argv.length < 2) {
22
+ process.stderr.write('usage: runner.mjs <host> <port> [protocol]\n');
23
+ process.exit(2);
24
+ }
25
+
26
+ const host = argv[0];
27
+ const port = Number(argv[1]);
28
+
29
+ let bots;
30
+ let negotiated;
31
+ try {
32
+ negotiated = argv.length > 2 ? Number(argv[2]) : await pingProtocol(host, port);
33
+ bots = new BotRegistry(host, port, versionForProtocol(negotiated));
34
+ } catch (error) {
35
+ // The Java side reads this line and reports it as the reason the runner did not start, so it
36
+ // has to arrive on stdout in protocol form rather than as a stack trace on stderr.
37
+ write(protocol.encode(protocol.ERROR, 'startup', `${error?.name ?? 'Error'}: ${error?.message ?? error}`));
38
+ process.stderr.write(`${error?.stack ?? error}\n`);
39
+ process.exit(1);
40
+ }
41
+
42
+ write(protocol.encode(protocol.READY, String(negotiated)));
43
+
44
+ const dispatch = new Dispatch(bots);
45
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
46
+
47
+ for await (const line of lines) {
48
+ const reply = await dispatch.handle(line);
49
+ if (reply === null) {
50
+ break;
51
+ }
52
+ if (reply !== '') {
53
+ write(reply);
54
+ }
55
+ }
56
+
57
+ bots.shutdown();
58
+ process.exit(0);
59
+ }
60
+
61
+ main().catch((error) => {
62
+ process.stderr.write(`${error?.stack ?? error}\n`);
63
+ process.exit(1);
64
+ });
65
+
66
+ function write(line) {
67
+ process.stdout.write(`${line}\n`);
68
+ }