vitaminmcp 2.2.0 → 3.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
@@ -60,6 +60,7 @@ agent leaves its host, ports and token where this server reads them.
60
60
  | `VITAMINMCP_RUNNER_JAR` | use this runner path instead of automatic selection |
61
61
  | `VITAMINMCP_NODE` | Node executable for the source runner fallback |
62
62
  | `VITAMINMCP_NODE_RUNNER` | bundled `runner.mjs` path when Node is available |
63
+ | `VITAMINMCP_VIEWER_PATH` | local viewer module to use instead of the pinned optional asset |
63
64
 
64
65
  ## What it downloads
65
66
 
@@ -71,8 +72,15 @@ matching this package's version, into `~/.vitaminmcp/jars/<version>/`:
71
72
  - otherwise one platform runner asset (`win-x64`, `linux-x64`, `linux-arm64`, `darwin-x64` or
72
73
  `darwin-arm64`), checked against a SHA-256 pinned into this package
73
74
 
74
- Both are checked against a SHA-256 pinned into this package at publish time. A file that does not
75
- match is deleted rather than run.
75
+ The trimmed `bot-runner-viewer-win-x64.tgz` is separate and is downloaded into
76
+ `~/.vitaminmcp/assets/<version>/` only when `bot_view` asks for a world view. It is checked against
77
+ the SHA-256 pinned into this package at publish time, extracted on first use, and never fetched by
78
+ an installation that only spawns bots or uses the inventory viewer. The current native viewer asset
79
+ is Windows x64; on Linux and macOS, set `VITAMINMCP_VIEWER_PATH` to a local sidecar until those
80
+ assets are released.
81
+
82
+ Every downloaded file is checked against its pinned SHA-256. A file that does not match is deleted
83
+ rather than run.
76
84
 
77
85
  Full documentation, design notes and the plugin itself:
78
86
  **[github.com/Backas03/VitaminMCP-minecraft](https://github.com/Backas03/VitaminMCP-minecraft)**
@@ -10,7 +10,7 @@ import {
10
10
  MCP_SERVER_JAR, assetPath, ensureAsset, ensureJar,
11
11
  } from '../lib/jars.mjs';
12
12
  import { checkJava, findJava } from '../lib/java.mjs';
13
- import { checkNode, findNode, runnerAssetName } from '../lib/node.mjs';
13
+ import { checkNode, findNode, runnerAssetName, viewerAssetName } from '../lib/node.mjs';
14
14
 
15
15
  const HERE = path.dirname(fileURLToPath(import.meta.url));
16
16
 
@@ -91,6 +91,13 @@ async function main() {
91
91
  const configuredRunner = process.env.VITAMINMCP_RUNNER_JAR;
92
92
  const node = findNode();
93
93
  const nodeCheck = checkNode(node);
94
+ let viewerAsset;
95
+ try {
96
+ viewerAsset = viewerAssetName();
97
+ } catch {
98
+ // The source runner can still be used on platforms whose native viewer asset is not released.
99
+ viewerAsset = '';
100
+ }
94
101
  const sourceRunner = process.env.VITAMINMCP_NODE_RUNNER
95
102
  ?? path.join(HERE, '..', 'runner', 'runner.mjs');
96
103
  let runner;
@@ -114,7 +121,7 @@ async function main() {
114
121
  }
115
122
 
116
123
  await runnerReady;
117
- return await run(java, server, runner);
124
+ return await run(java, server, runner, release, viewerAsset);
118
125
  }
119
126
 
120
127
  /**
@@ -124,10 +131,20 @@ async function main() {
124
131
  * `.part` file it leaves is claimed again by the next start, and a client waiting on a process
125
132
  * that no longer serves anything is worse than a jar fetched twice.
126
133
  */
127
- function run(java, server, runner) {
134
+ function run(java, server, runner, release, viewerAsset) {
135
+ const env = {
136
+ ...process.env,
137
+ VITAMINMCP_RUNNER_JAR: runner,
138
+ VITAMINMCP_VERSION: release,
139
+ VITAMINMCP_VIEWER_ASSET: viewerAsset,
140
+ };
141
+ // The loader imports the pinned archive only when the Node runner receives bot_view(world).
142
+ if (!env.VITAMINMCP_VIEWER_PATH) {
143
+ env.VITAMINMCP_VIEWER_PATH = path.join(HERE, '..', 'lib', 'viewer-loader.mjs');
144
+ }
128
145
  const child = spawn(java, ['-jar', server], {
129
146
  stdio: 'inherit',
130
- env: { ...process.env, VITAMINMCP_RUNNER_JAR: runner },
147
+ env,
131
148
  });
132
149
 
133
150
  return new Promise((resolve) => {
package/checksums.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
- "version": "2.2.0",
2
+ "version": "3.0.0",
3
3
  "jars": {
4
- "mcp-server.jar": "27f1543ced4e59ed6501aa4238eab76473cbf4ff1612a4622b5bb27455447685"
4
+ "mcp-server.jar": "576610b6f7deb691fad5633533678ef7a9266574ad731094cc78a01bb24be480"
5
5
  },
6
6
  "assets": {
7
- "bot-runner-win-x64.exe": "82438774fc5b43e1a348daade6cc22e5be1e97f39cd63ee8b8b65afa242b2109"
7
+ "bot-runner-win-x64.exe": "1a762d0773269ea3205ccb6816e964f33f3ddd15fd1003c6cadd5af4460572ca",
8
+ "bot-runner-linux-x64": "a2e975d331461fc699c1998e86a847f8ca161a266caae57c6060bc74bf87569c",
9
+ "bot-runner-linux-arm64": "31aad6207a268ddb70298ca1eaa2d443c2f097e1e5b714a2db6b40eea57ec749",
10
+ "bot-runner-darwin-x64": "573eb872b1965fe6b8575611f42336fd4dda436e749196e4a1373cffd2939bde",
11
+ "bot-runner-darwin-arm64": "853183f7da381f9b831aef9824c69563a732205c61d300fad908cd0f502a73e0",
12
+ "bot-runner-viewer-win-x64.tgz": "e39a36cdf92b9223aa7c9610f8231afc7a577e8ed007bc3571895bd945baf3e1"
8
13
  }
9
14
  }
package/lib/jars.mjs CHANGED
@@ -14,6 +14,9 @@ const RELEASES = 'https://github.com/Backas03/VitaminMCP-minecraft/releases/down
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
16
 
17
+ /** The optional viewer archive fetched only when a world view is requested. */
18
+ export const VIEWER_ASSET = 'bot-runner-viewer-win-x64.tgz';
19
+
17
20
  let checksumsPromise;
18
21
  let manifestPromise;
19
22
 
package/lib/node.mjs CHANGED
@@ -33,11 +33,21 @@ export function checkNode(node) {
33
33
  /** The release asset name for the current SEA target. */
34
34
  export function runnerAssetName(platform = process.platform, arch = process.arch) {
35
35
  if (platform === 'win32' && arch === 'x64') return 'bot-runner-win-x64.exe';
36
+ if (platform === 'linux' && arch === 'x64') return 'bot-runner-linux-x64';
37
+ if (platform === 'linux' && arch === 'arm64') return 'bot-runner-linux-arm64';
38
+ if (platform === 'darwin' && arch === 'x64') return 'bot-runner-darwin-x64';
39
+ if (platform === 'darwin' && arch === 'arm64') return 'bot-runner-darwin-arm64';
40
+ throw new Error(`No VitaminMCP runner asset exists for ${platform}-${arch}.`);
41
+ }
42
+
43
+ /** The optional world-view asset for the platforms currently published. */
44
+ export function viewerAssetName(platform = process.platform, arch = process.arch) {
45
+ if (platform === 'win32' && arch === 'x64') return 'bot-runner-viewer-win-x64.tgz';
36
46
  if (platform === 'linux' || platform === 'darwin') {
37
47
  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.',
48
+ `Optional world viewer assets for ${platform}-${arch} are planned but not released yet. `
49
+ + 'Set VITAMINMCP_VIEWER_PATH to a local prismarine-viewer sidecar.',
40
50
  );
41
51
  }
42
- throw new Error(`No VitaminMCP runner asset exists for ${platform}-${arch}.`);
52
+ throw new Error(`No VitaminMCP world viewer asset exists for ${platform}-${arch}.`);
43
53
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Lazy bridge from the npm launcher's pinned asset fetcher to the Node runner's viewer import.
3
+ *
4
+ * This module is imported only when `bot_view` asks for a world view. The normal runner startup
5
+ * therefore never downloads or extracts the optional viewer archive.
6
+ */
7
+
8
+ import { spawn } from 'node:child_process';
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+
13
+ import {
14
+ assetCacheDirectory,
15
+ ensureAsset,
16
+ } from './jars.mjs';
17
+
18
+ const version = process.env.VITAMINMCP_VERSION;
19
+ if (!version) throw new Error('VITAMINMCP_VERSION is required to load the optional viewer asset.');
20
+ const asset = process.env.VITAMINMCP_VIEWER_ASSET;
21
+ if (!asset) {
22
+ throw new Error(
23
+ 'No packaged world viewer asset exists for this platform. '
24
+ + 'Set VITAMINMCP_VIEWER_PATH to a local prismarine-viewer sidecar.',
25
+ );
26
+ }
27
+
28
+ const archive = await ensureAsset(version, asset, {
29
+ log: (message) => process.stderr.write(`[vitaminmcp] ${message}\n`),
30
+ });
31
+ const extractionRoot = path.join(assetCacheDirectory(version), 'viewer');
32
+ const entry = path.join(extractionRoot, 'package', 'node_modules', 'prismarine-viewer', 'index.js');
33
+ const marker = path.join(extractionRoot, '.ready');
34
+
35
+ if (!(await exists(marker)) || !(await exists(entry))) {
36
+ await fs.rm(extractionRoot, { recursive: true, force: true });
37
+ await fs.mkdir(extractionRoot, { recursive: true });
38
+ await extract(archive, extractionRoot);
39
+ if (!(await exists(entry))) {
40
+ throw new Error(`The viewer asset did not contain ${entry}.`);
41
+ }
42
+ await fs.writeFile(marker, 'ready\n');
43
+ }
44
+
45
+ const viewerModule = await import(pathToFileURL(entry).href);
46
+ const viewer = viewerModule.default ?? viewerModule;
47
+
48
+ export default viewer;
49
+ export const mineflayer = viewer.mineflayer;
50
+ export const supportedVersions = viewer.supportedVersions;
51
+
52
+ async function exists(file) {
53
+ try {
54
+ await fs.access(file);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function extract(file, directory) {
62
+ return new Promise((resolve, reject) => {
63
+ const child = spawn('tar', ['-xzf', file, '-C', directory], {
64
+ stdio: ['ignore', 'ignore', 'pipe'],
65
+ });
66
+ let error = '';
67
+ child.stderr.on('data', (chunk) => { error += chunk; });
68
+ child.once('error', reject);
69
+ child.once('close', (code) => {
70
+ if (code === 0) resolve();
71
+ else reject(new Error(`Could not extract viewer asset (tar exited ${code}): ${error}`));
72
+ });
73
+ });
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitaminmcp",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
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": [
@@ -51,6 +51,12 @@ const DIG_ACK_TIMEOUT_MILLIS = 1500;
51
51
  /** How long the resulting block change gets to come back after the confirmation. */
52
52
  const DIG_SETTLE_MILLIS = 300;
53
53
 
54
+ /** How long a client gets to receive the block before an interaction is sent. */
55
+ const BLOCK_KNOWN_TIMEOUT_MILLIS = 15_000;
56
+
57
+ /** One client tick for a block update already queued by a preceding server command. */
58
+ const BLOCK_UPDATE_SETTLE_MILLIS = 50;
59
+
54
60
  /**
55
61
  * Breaks a block, and says what became of the attempt.
56
62
  *
@@ -158,7 +164,7 @@ export function chat(bot, name, message) {
158
164
  }
159
165
 
160
166
  /** Right-clicks a block — which is how a container or a plugin menu gets opened. */
161
- export function useBlock(bot, name, x, y, z, face) {
167
+ export async function useBlock(bot, name, x, y, z, face) {
162
168
  requireInWorld(bot, name);
163
169
 
164
170
  const direction = !face || !face.trim() ? FACES.up : FACES[face.trim().toLowerCase()];
@@ -168,6 +174,9 @@ export function useBlock(bot, name, x, y, z, face) {
168
174
  );
169
175
  }
170
176
 
177
+ const location = new Vec3(x, y, z);
178
+ await blockKnown(bot, name, location);
179
+
171
180
  bot._client.write('block_place', {
172
181
  hand: MAIN_HAND,
173
182
  location: { x, y, z },
@@ -181,6 +190,33 @@ export function useBlock(bot, name, x, y, z, face) {
181
190
  });
182
191
  }
183
192
 
193
+ /**
194
+ * Waits until the client has a block record for an interaction target.
195
+ *
196
+ * <p>A console command can change a block before its block update reaches the bot. Sending the
197
+ * interaction in that gap is legal at the protocol level, but the client has not loaded the target
198
+ * yet and Paper may drop the window-opening packet. This was the intermittent compatibility
199
+ * failure on the otherwise deterministic chest check.
200
+ */
201
+ async function blockKnown(bot, name, location) {
202
+ const deadline = Date.now() + BLOCK_KNOWN_TIMEOUT_MILLIS;
203
+ while (Date.now() < deadline) {
204
+ if (bot.blockAt(location)) {
205
+ // A command_exec that changed this block has already completed on the server, but the
206
+ // corresponding packet may still be queued on the bot socket. Give that one client tick
207
+ // a chance to settle before sending the interaction packet.
208
+ await delay(BLOCK_UPDATE_SETTLE_MILLIS);
209
+ return;
210
+ }
211
+ await delay(50);
212
+ }
213
+
214
+ throw new Error(
215
+ `Bot ${name} was not told about the block at ${location.x} ${location.y} ${location.z} within `
216
+ + `${BLOCK_KNOWN_TIMEOUT_MILLIS}ms`,
217
+ );
218
+ }
219
+
184
220
  /** Right-clicks the nearest entity to a point, and returns which one it was. */
185
221
  export function useEntity(bot, name, x, y, z, radius, type) {
186
222
  requireInWorld(bot, name);
@@ -3,7 +3,7 @@ import { configurePathfinder, loadPathfinder, moveTo } from './movement.mjs';
3
3
  import { reachable } from './movement.mjs';
4
4
  import { stopAllViews, stopView as stopBotView, view as startView } from './viewer.mjs';
5
5
 
6
- import { collect } from './clientview.mjs';
6
+ import { collect, forget } from './clientview.mjs';
7
7
  import { addressField, identity } from './identity.mjs';
8
8
 
9
9
  /** How long a bot has to get from a socket to standing in the world. */
@@ -58,7 +58,7 @@ export class BotRegistry {
58
58
  // Before waiting to join, not after: messages are events, and a plugin that greets or refuses
59
59
  // on join says so within a tick of the bot arriving. Attaching afterwards loses exactly the
60
60
  // messages most worth having.
61
- collect(bot);
61
+ collect(bot, name);
62
62
 
63
63
  try {
64
64
  await joined(bot, name);
@@ -108,6 +108,7 @@ export class BotRegistry {
108
108
  return;
109
109
  }
110
110
  this.#bots.delete(name);
111
+ forget(name);
111
112
  stopBotView(bot);
112
113
  quietly(() => bot.quit());
113
114
  }
@@ -119,7 +120,8 @@ export class BotRegistry {
119
120
  /** Disconnects every bot. */
120
121
  shutdown() {
121
122
  stopAllViews();
122
- for (const bot of this.#bots.values()) {
123
+ for (const [name, bot] of this.#bots) {
124
+ forget(name);
123
125
  quietly(() => bot.quit());
124
126
  }
125
127
  this.#bots.clear();
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
1
3
  import { menu } from './actions.mjs';
2
4
  import { plainText, stripLegacyCodes, untag } from './text.mjs';
3
5
 
@@ -20,27 +22,63 @@ const MAX_MESSAGES = 100;
20
22
 
21
23
  const state = new WeakMap();
22
24
 
25
+ /**
26
+ * The active message stream for each bot name.
27
+ *
28
+ * Each client connection gets a random id, so a cursor cannot be mistaken for one from another
29
+ * session, runner process or same-named replacement bot.
30
+ */
31
+ const messageStreamsByName = new Map();
32
+
33
+ /**
34
+ * Drops the message stream a despawned bot left behind.
35
+ *
36
+ * The stream outlives the bot on purpose while a replacement is connecting, but a bot that is gone
37
+ * for good would otherwise keep its entry for the lifetime of the runner. The next bot of the same
38
+ * name gets a new id either way, so nothing that survives here is ever reused.
39
+ */
40
+ export function forget(name) {
41
+ const stream = messageStreamsByName.get(name);
42
+ if (stream) stream.activeCollector = null;
43
+ messageStreamsByName.delete(name);
44
+ }
45
+
23
46
  /**
24
47
  * Starts collecting what the server tells this bot.
25
48
  *
26
49
  * Must be called as soon as the bot exists: this is all events, so anything said before the
27
50
  * listeners attach is gone, and a plugin greets or refuses within a tick of the bot arriving.
28
51
  */
29
- export function collect(bot) {
52
+ export function collect(bot, name) {
53
+ const previous = messageStreamsByName.get(name);
54
+ if (previous) previous.activeCollector = null;
55
+
56
+ const stream = { id: randomUUID(), nextSequence: 0, activeCollector: null };
57
+
30
58
  const own = {
31
59
  messages: [],
32
60
  bossBars: new Map(),
33
61
  objectiveTitles: new Map(),
34
62
  objectiveScores: new Map(),
35
63
  sidebar: null,
64
+ messageStream: stream,
36
65
  };
66
+ stream.activeCollector = own;
67
+ messageStreamsByName.set(name, stream);
37
68
  state.set(bot, own);
38
69
 
39
70
  const remember = (text) => {
71
+ // Closing a replaced bot is asynchronous. Ignore anything its socket delivers after the new
72
+ // bot took over, otherwise an invisible message would consume a sequence and look like loss.
73
+ if (stream.activeCollector !== own) {
74
+ return;
75
+ }
40
76
  if (text == null || String(text).trim() === '') {
41
77
  return;
42
78
  }
43
- own.messages.push(String(text));
79
+ const sequence = stream.nextSequence;
80
+ stream.nextSequence += 1;
81
+ own.messages.push({ sequence, timestamp: Date.now(), text: String(text) });
44
82
  while (own.messages.length > MAX_MESSAGES) {
45
83
  own.messages.shift();
46
84
  }
@@ -90,10 +128,13 @@ export function collect(bot) {
90
128
  /** Everything the client was told, in the shape `RunnerDispatch` writes. */
91
129
  export function inspect(bot, name) {
92
130
  const own = state.get(bot) ?? { messages: [], bossBars: new Map() };
131
+ const stream = own.messageStream ?? messageStreamsByName.get(name);
93
132
  return {
94
133
  menu: menu(bot, name),
95
134
  items: menuItems(bot),
96
135
  messages: [...own.messages],
136
+ nextMessageSequence: stream?.nextSequence ?? 0,
137
+ messageStreamId: stream?.id ?? '',
97
138
  bossBars: [...own.bossBars.values()],
98
139
  scoreboard: sidebarOf(own),
99
140
  health: Number.isFinite(bot.health) ? bot.health : null,
@@ -86,7 +86,7 @@ export class Dispatch {
86
86
  }
87
87
 
88
88
  case protocol.USE: {
89
- actions.useBlock(
89
+ await actions.useBlock(
90
90
  this.#bots.require(command[1]),
91
91
  command[1],
92
92
  Number(command[2]),
@@ -208,7 +208,7 @@ export class Dispatch {
208
208
  String(open === null ? -1 : open.containerId),
209
209
  open === null ? '' : protocol.sanitize(open.title),
210
210
  items(view.items),
211
- recordList(view.messages),
211
+ messageList(view.messages),
212
212
  bossBarList(view.bossBars),
213
213
  board === null ? '' : protocol.sanitize(board.title),
214
214
  board === null ? '' : recordList(board.lines),
@@ -218,6 +218,8 @@ export class Dispatch {
218
218
  view.totalExperience == null ? '' : String(view.totalExperience),
219
219
  view.experienceProgress == null ? '' : protocol.javaFloat(view.experienceProgress),
220
220
  recordList(view.effects),
221
+ String(view.nextMessageSequence),
222
+ protocol.sanitize(view.messageStreamId),
221
223
  );
222
224
  }
223
225
 
@@ -264,6 +266,17 @@ function items(list) {
264
266
  .join(protocol.RECORD_SEPARATOR);
265
267
  }
266
268
 
269
+ /** `sequence ␟ timestamp ␟ text`, oldest first, joined by ␞. */
270
+ function messageList(messages) {
271
+ return messages
272
+ .map((message) => [
273
+ message.sequence,
274
+ message.timestamp,
275
+ protocol.sanitize(message.text),
276
+ ].join(protocol.UNIT_SEPARATOR))
277
+ .join(protocol.RECORD_SEPARATOR);
278
+ }
279
+
267
280
  /** `title ␟ progress ␟ colour`, one record each. */
268
281
  function bossBarList(bars) {
269
282
  return bars