vitaminmcp 3.1.0 → 3.1.1

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/checksums.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
- "version": "3.1.0",
2
+ "version": "3.1.1",
3
3
  "jars": {
4
- "mcp-server.jar": "f64ee16d5aaa115a9d4e019adccf830b74ebe8683d2ade5e05b7db0cf9692d66"
4
+ "mcp-server.jar": "423c885926a6c5f82d1c403ce01592afabfcc0646b756c5ce7370a0a88be09b1"
5
5
  },
6
6
  "assets": {
7
- "bot-runner-win-x64.exe": "8af36a3e4a3678db5246777c1c8080bea230b50a742dff173758d0513df74e98",
8
- "bot-runner-linux-x64": "2f344983a5558d36cf03e4bcf55e0752324d159aebefc18f711c28704cf48264",
9
- "bot-runner-linux-arm64": "fa6c49b5db5b6cc84b62d295bbef2ace49305400a7480e86baa009e9629d120c",
10
- "bot-runner-darwin-x64": "1769db980857d36204bf93e14bbe5ba329435032b116c8003a12043ba817168a",
11
- "bot-runner-darwin-arm64": "b137677b9117af944d006ff0da0e94b524875c86ebfa2596c2b856b9ef63fd2e",
7
+ "bot-runner-win-x64.exe": "0f97651dd8bdc9861b587780ced4b94711ccaf52e5383ca1d61796b89c6c9947",
8
+ "bot-runner-linux-x64": "c178ef44dfb36f9e0fab3b2e78ba954e670bc0e227c49a2ef00de345ef96c6d6",
9
+ "bot-runner-linux-arm64": "65cd7adb1c35fbfe20671b59f9bb95b15ba9b0678646b3c4ce53d24e93024617",
10
+ "bot-runner-darwin-x64": "8c67b91564c8db96e27c817e0adaa778426ba5cc300ea6e2cd71c6aeb00e9737",
11
+ "bot-runner-darwin-arm64": "a0a6467a8fe08254fa4d1e830d16fad3368d6f8e8fb2ba18e078a7073243cbb9",
12
12
  "bot-runner-viewer-win-x64.tgz": "e39a36cdf92b9223aa7c9610f8231afc7a577e8ed007bc3571895bd945baf3e1"
13
13
  }
14
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitaminmcp",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
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": [
@@ -25,7 +25,8 @@
25
25
  "type": "module",
26
26
  "dependencies": {
27
27
  "mineflayer": "^4.37.1",
28
- "mineflayer-pathfinder": "2.4.5"
28
+ "mineflayer-pathfinder": "2.4.5",
29
+ "prismarine-auth": "^3.1.1"
29
30
  },
30
31
  "bin": {
31
32
  "vitaminmcp": "bin/vitaminmcp.mjs"
@@ -1,4 +1,8 @@
1
1
  import mineflayer from 'mineflayer';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import prismarineAuth from 'prismarine-auth';
2
6
  import { configurePathfinder, loadPathfinder, moveTo } from './movement.mjs';
3
7
  import { reachable } from './movement.mjs';
4
8
  import { stopAllViews, stopView as stopBotView, view as startView } from './viewer.mjs';
@@ -7,6 +11,12 @@ import { collect, forget } from './clientview.mjs';
7
11
  import { addressField, identity } from './identity.mjs';
8
12
  import { answerResourcePacks, describeProgress, traceProgress } from './join.mjs';
9
13
 
14
+ const { Authflow } = prismarineAuth;
15
+
16
+ const DEFAULT_ACCOUNTS_DIRECTORY = resolve(
17
+ process.env.VITAMINMCP_ACCOUNTS_DIR ?? join(homedir(), '.vitaminmcp', 'accounts'),
18
+ );
19
+
10
20
  /** How long a bot has to get from a socket to standing in the world. */
11
21
  const LOGIN_TIMEOUT_MILLIS = 30_000;
12
22
 
@@ -28,16 +38,19 @@ export class BotRegistry {
28
38
 
29
39
  #version;
30
40
 
41
+ #accounts;
42
+
31
43
  #bots = new Map();
32
44
 
33
- constructor(host, port, version) {
45
+ constructor(host, port, version, accounts = new MicrosoftAccounts()) {
34
46
  this.#host = host;
35
47
  this.#port = port;
36
48
  this.#version = version;
49
+ this.#accounts = accounts;
37
50
  }
38
51
 
39
52
  /** Connects a bot and waits until it is standing in the world. */
40
- async spawn(name, clientIp) {
53
+ async spawn(name, clientIp, auth = 'offline', account = '') {
41
54
  // The Java runner overwrites the map entry, so a repeated spawn is not an error there and must
42
55
  // not become one here. Closing the old socket first is the only difference: leaving it open
43
56
  // would hold a player slot under a name this runner no longer tracks.
@@ -47,12 +60,40 @@ export class BotRegistry {
47
60
  quietly(() => existing.quit());
48
61
  }
49
62
 
50
- const id = identity(name);
51
- // Normal logins use the server's ordinary offline UUID for this name, which stays stable when
52
- // a test reuses the same bot. A clientIp is an explicit request for the opt-in BungeeCord
53
- // forwarding handshake used by tests that need a spoofed address or UUID.
63
+ const mode = authenticationMode(auth);
64
+ if (mode === 'microsoft' && clientIp?.trim()) {
65
+ throw new Error('clientIp forwarding is only available for offline bots');
66
+ }
67
+
68
+ const accountKey = account?.trim() || name;
69
+ const authenticated = mode === 'microsoft'
70
+ ? await this.#accounts.require(accountKey)
71
+ : null;
72
+ if (authenticated && authenticated.profile.name.toLowerCase() !== name.toLowerCase()) {
73
+ throw new Error(
74
+ `Microsoft account '${accountKey}' owns profile '${authenticated.profile.name}', `
75
+ + `not '${name}'. Call bot_spawn with name='${authenticated.profile.name}'.`,
76
+ );
77
+ }
78
+
79
+ const id = authenticated
80
+ ? identity(authenticated.profile.name, { uuid: canonicalUuid(authenticated.profile.id) })
81
+ : identity(name);
54
82
  const bot = mineflayer.createBot(
55
- connectionOptions(this.#host, this.#port, this.#version, id, clientIp),
83
+ connectionOptions(
84
+ this.#host,
85
+ this.#port,
86
+ this.#version,
87
+ id,
88
+ clientIp,
89
+ mode,
90
+ accountKey,
91
+ authenticated?.profilesFolder,
92
+ (code) => {
93
+ this.#accounts.invalidate(accountKey);
94
+ throw new Error(loginRequired(accountKey, code));
95
+ },
96
+ ),
56
97
  );
57
98
  loadPathfinder(bot);
58
99
 
@@ -84,7 +125,11 @@ export class BotRegistry {
84
125
  configurePathfinder(bot);
85
126
  await settle(bot, name);
86
127
  await worldKnown(bot, name);
87
- return position(bot);
128
+ return {
129
+ ...position(bot),
130
+ playerName: id.name,
131
+ uuid: id.uuid,
132
+ };
88
133
  }
89
134
 
90
135
  async move(name, x, y, z, mode, timeoutMillis) {
@@ -144,24 +189,151 @@ export class BotRegistry {
144
189
  }
145
190
  }
146
191
 
147
- /** Builds a normal login, adding BungeeCord forwarding only for an explicit clientIp test. */
148
- export function connectionOptions(host, port, version, id, clientIp) {
192
+ /** Builds a login, adding BungeeCord forwarding only for an explicit offline-mode IP test. */
193
+ export function connectionOptions(
194
+ host,
195
+ port,
196
+ version,
197
+ id,
198
+ clientIp,
199
+ auth = 'offline',
200
+ account = id.name,
201
+ profilesFolder = DEFAULT_ACCOUNTS_DIRECTORY,
202
+ onMsaCode = undefined,
203
+ ) {
204
+ const mode = authenticationMode(auth);
149
205
  const options = {
150
206
  host,
151
207
  port,
152
- username: id.name,
153
- auth: 'offline',
208
+ username: mode === 'microsoft' ? account : id.name,
209
+ auth: mode,
154
210
  version,
155
211
  checkTimeoutInterval: LOGIN_TIMEOUT_MILLIS,
156
212
  // mineflayer's default console.logs bot errors, and stdout is the protocol channel.
157
213
  logErrors: false,
158
214
  };
159
- if (clientIp && clientIp.trim()) {
215
+ if (mode === 'microsoft') {
216
+ options.profilesFolder = profilesFolder;
217
+ options.onMsaCode = onMsaCode;
218
+ } else if (clientIp && clientIp.trim()) {
160
219
  options.fakeHost = addressField(host, clientIp.trim(), id);
161
220
  }
162
221
  return options;
163
222
  }
164
223
 
224
+ /** Microsoft device-code authentication that survives a failed first spawn through its cache. */
225
+ export class MicrosoftAccounts {
226
+ #directory;
227
+
228
+ #createFlow;
229
+
230
+ #states = new Map();
231
+
232
+ constructor(
233
+ directory = DEFAULT_ACCOUNTS_DIRECTORY,
234
+ createFlow = (account, profilesFolder, onCode) => new Authflow(
235
+ account,
236
+ profilesFolder,
237
+ undefined,
238
+ onCode,
239
+ ),
240
+ ) {
241
+ this.#directory = resolve(directory);
242
+ this.#createFlow = createFlow;
243
+ }
244
+
245
+ async require(account) {
246
+ let state = this.#states.get(account);
247
+ if (!state) {
248
+ state = this.#start(account);
249
+ this.#states.set(account, state);
250
+ }
251
+ if (state.status === 'starting') {
252
+ await state.firstUpdate;
253
+ }
254
+ if (state.status === 'ready') {
255
+ return { profile: state.profile, profilesFolder: this.#directory };
256
+ }
257
+ if (state.status === 'failed') {
258
+ this.#states.delete(account);
259
+ throw state.error;
260
+ }
261
+ throw new Error(loginRequired(account, state.code));
262
+ }
263
+
264
+ invalidate(account) {
265
+ this.#states.delete(account);
266
+ }
267
+
268
+ #start(account) {
269
+ let announce;
270
+ const state = {
271
+ status: 'starting',
272
+ code: null,
273
+ profile: null,
274
+ error: null,
275
+ firstUpdate: new Promise((resolveFirstUpdate) => {
276
+ announce = resolveFirstUpdate;
277
+ }),
278
+ };
279
+
280
+ const authenticate = async () => {
281
+ await mkdir(this.#directory, { recursive: true, mode: 0o700 });
282
+ const flow = this.#createFlow(account, this.#directory, (code) => {
283
+ state.status = 'pending';
284
+ state.code = code;
285
+ announce();
286
+ });
287
+ const result = await flow.getMinecraftJavaToken({
288
+ fetchProfile: true,
289
+ fetchCertificates: false,
290
+ });
291
+ if (!result.profile?.name || !result.profile?.id) {
292
+ throw new Error(`Microsoft account '${account}' has no Minecraft Java profile`);
293
+ }
294
+ state.status = 'ready';
295
+ state.profile = result.profile;
296
+ announce();
297
+ };
298
+
299
+ authenticate().catch((error) => {
300
+ state.status = 'failed';
301
+ state.error = error instanceof Error ? error : new Error(String(error));
302
+ announce();
303
+ });
304
+ return state;
305
+ }
306
+ }
307
+
308
+ function authenticationMode(auth) {
309
+ const mode = String(auth || 'offline').toLowerCase();
310
+ if (mode !== 'offline' && mode !== 'microsoft') {
311
+ throw new Error(`unknown bot auth '${auth}'; use offline or microsoft`);
312
+ }
313
+ return mode;
314
+ }
315
+
316
+ function loginRequired(account, code) {
317
+ const uri = code?.verification_uri || 'https://www.microsoft.com/link';
318
+ const userCode = code?.user_code || '(request a new code)';
319
+ return `Microsoft login required for account '${account}'. Open ${uri} and enter code `
320
+ + `${userCode}, complete sign-in, then call bot_spawn again with the same name and account.`;
321
+ }
322
+
323
+ function canonicalUuid(value) {
324
+ const compact = String(value).replaceAll('-', '');
325
+ if (!/^[0-9a-fA-F]{32}$/.test(compact)) {
326
+ throw new Error(`Microsoft returned an invalid Minecraft profile UUID: ${value}`);
327
+ }
328
+ return [
329
+ compact.slice(0, 8),
330
+ compact.slice(8, 12),
331
+ compact.slice(12, 16),
332
+ compact.slice(16, 20),
333
+ compact.slice(20),
334
+ ].join('-').toLowerCase();
335
+ }
336
+
165
337
  function position(bot) {
166
338
  const at = bot.entity?.position;
167
339
  return at ? { x: at.x, y: at.y, z: at.z } : { x: 0, y: 0, z: 0 };
@@ -39,7 +39,9 @@ export class Dispatch {
39
39
  switch (verb) {
40
40
  case protocol.SPAWN: {
41
41
  const clientIp = command.length > 2 ? command[2] : '';
42
- return positionLine(verb, await this.#bots.spawn(command[1], clientIp));
42
+ const auth = command.length > 3 ? command[3] : 'offline';
43
+ const account = command.length > 4 ? command[4] : '';
44
+ return spawnLine(verb, await this.#bots.spawn(command[1], clientIp, auth, account));
43
45
  }
44
46
 
45
47
  case protocol.DESPAWN: {
@@ -302,3 +304,15 @@ function positionLine(verb, at) {
302
304
  protocol.javaDouble(at.z),
303
305
  );
304
306
  }
307
+
308
+ function spawnLine(verb, at) {
309
+ return protocol.encode(
310
+ protocol.OK,
311
+ verb,
312
+ protocol.javaDouble(at.x),
313
+ protocol.javaDouble(at.y),
314
+ protocol.javaDouble(at.z),
315
+ protocol.sanitize(at.playerName),
316
+ protocol.sanitize(at.uuid),
317
+ );
318
+ }