vitaminmcp 3.0.3 → 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/README.md +3 -2
- package/checksums.json +7 -7
- package/package.json +3 -2
- package/runner/runner.mjs +6 -0
- package/runner/src/actions.mjs +36 -17
- package/runner/src/bots.mjs +187 -13
- package/runner/src/dispatch.mjs +17 -2
package/README.md
CHANGED
|
@@ -43,12 +43,13 @@ agent leaves its host, ports and token where this server reads them.
|
|
|
43
43
|
- Open, read, click and assert on inventories and plugin GUIs
|
|
44
44
|
- Wait for events and conditions instead of sleeping
|
|
45
45
|
- Read live server state: events, logs, exceptions, permissions
|
|
46
|
-
- Paper / Purpur 1.21 through 1
|
|
46
|
+
- Paper / Purpur 1.21 through 26.1, from one install
|
|
47
47
|
|
|
48
48
|
## Requires
|
|
49
49
|
|
|
50
50
|
- **Java 21 or later** on this machine — the jars run on the JVM. Point `JAVA_HOME` at it, or have
|
|
51
|
-
`java` on `PATH
|
|
51
|
+
`java` on `PATH`. (The Paper server itself needs Java 25 from 26.1 on; that is Paper's
|
|
52
|
+
requirement, and the agent loads there unchanged)
|
|
52
53
|
- **Node 18.17 or later** when using the source runner fallback. If Node is absent, the launcher
|
|
53
54
|
selects a pinned platform runner asset instead.
|
|
54
55
|
- **Paper 1.21 or later** on the Minecraft server, with `VitaminMCP.jar` in its `plugins/`
|
package/checksums.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.
|
|
2
|
+
"version": "3.1.1",
|
|
3
3
|
"jars": {
|
|
4
|
-
"mcp-server.jar": "
|
|
4
|
+
"mcp-server.jar": "423c885926a6c5f82d1c403ce01592afabfcc0646b756c5ce7370a0a88be09b1"
|
|
5
5
|
},
|
|
6
6
|
"assets": {
|
|
7
|
-
"bot-runner-win-x64.exe": "
|
|
8
|
-
"bot-runner-linux-x64": "
|
|
9
|
-
"bot-runner-linux-arm64": "
|
|
10
|
-
"bot-runner-darwin-x64": "
|
|
11
|
-
"bot-runner-darwin-arm64": "
|
|
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.
|
|
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"
|
package/runner/runner.mjs
CHANGED
|
@@ -16,6 +16,12 @@ import { Dispatch } from './src/dispatch.mjs';
|
|
|
16
16
|
import * as protocol from './src/protocol.mjs';
|
|
17
17
|
import { pingProtocol, versionForProtocol } from './src/version.mjs';
|
|
18
18
|
|
|
19
|
+
// stdout is the protocol channel; a dependency's console.log (mineflayer did this on 26.1, with a
|
|
20
|
+
// stack trace) desynchronises every later reply. `write` below is the only path to stdout.
|
|
21
|
+
for (const level of ['log', 'info', 'debug']) {
|
|
22
|
+
console[level] = (...messages) => process.stderr.write(`${messages.map(String).join(' ')}\n`);
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
async function main() {
|
|
20
26
|
const argv = process.argv.slice(2);
|
|
21
27
|
if (argv.length < 2) {
|
package/runner/src/actions.mjs
CHANGED
|
@@ -234,26 +234,45 @@ export function useEntity(bot, name, x, y, z, radius, type) {
|
|
|
234
234
|
);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
237
|
+
if (interactCarriesMouseButton(bot)) {
|
|
238
|
+
// Both packets, in this order, because that is what a real client sends and what the Java
|
|
239
|
+
// runner reproduces: a plugin listening only for the second sees nothing without the first.
|
|
240
|
+
bot._client.write('use_entity', {
|
|
241
|
+
target: entityId,
|
|
242
|
+
mouse: INTERACT_AT,
|
|
243
|
+
x: 0.0,
|
|
244
|
+
y: 1.0,
|
|
245
|
+
z: 0.0,
|
|
246
|
+
hand: MAIN_HAND,
|
|
247
|
+
sneaking: false,
|
|
248
|
+
});
|
|
249
|
+
bot._client.write('use_entity', {
|
|
250
|
+
target: entityId,
|
|
251
|
+
mouse: INTERACT,
|
|
252
|
+
hand: MAIN_HAND,
|
|
253
|
+
sneaking: false,
|
|
254
|
+
});
|
|
255
|
+
} else {
|
|
256
|
+
// 26.1: one packet with a hand and a hit position; attacks have their own packet.
|
|
257
|
+
bot._client.write('use_entity', {
|
|
258
|
+
target: entityId,
|
|
259
|
+
hand: MAIN_HAND,
|
|
260
|
+
location: { x: 0.0, y: 1.0, z: 0.0 },
|
|
261
|
+
sneaking: false,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
254
264
|
return entityId;
|
|
255
265
|
}
|
|
256
266
|
|
|
267
|
+
/** Through 1.21.11 `use_entity` has a `mouse` field; from 26.1 it has a `location` instead. */
|
|
268
|
+
function interactCarriesMouseButton(bot) {
|
|
269
|
+
const fields = bot.registry?.protocol?.play?.toServer?.types?.packet_use_entity?.[1];
|
|
270
|
+
if (!Array.isArray(fields)) {
|
|
271
|
+
throw new Error('The protocol definition for use_entity is missing, so the bot cannot interact with entities.');
|
|
272
|
+
}
|
|
273
|
+
return fields.some((field) => field?.name === 'mouse');
|
|
274
|
+
}
|
|
275
|
+
|
|
257
276
|
/** Left-clicks the nearest tracked entity. */
|
|
258
277
|
export function attackEntity(bot, name, x, y, z, radius, type) {
|
|
259
278
|
requireInWorld(bot, name);
|
package/runner/src/bots.mjs
CHANGED
|
@@ -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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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(
|
|
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
|
|
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,22 +189,151 @@ export class BotRegistry {
|
|
|
144
189
|
}
|
|
145
190
|
}
|
|
146
191
|
|
|
147
|
-
/** Builds a
|
|
148
|
-
export function connectionOptions(
|
|
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:
|
|
208
|
+
username: mode === 'microsoft' ? account : id.name,
|
|
209
|
+
auth: mode,
|
|
154
210
|
version,
|
|
155
211
|
checkTimeoutInterval: LOGIN_TIMEOUT_MILLIS,
|
|
212
|
+
// mineflayer's default console.logs bot errors, and stdout is the protocol channel.
|
|
213
|
+
logErrors: false,
|
|
156
214
|
};
|
|
157
|
-
if (
|
|
215
|
+
if (mode === 'microsoft') {
|
|
216
|
+
options.profilesFolder = profilesFolder;
|
|
217
|
+
options.onMsaCode = onMsaCode;
|
|
218
|
+
} else if (clientIp && clientIp.trim()) {
|
|
158
219
|
options.fakeHost = addressField(host, clientIp.trim(), id);
|
|
159
220
|
}
|
|
160
221
|
return options;
|
|
161
222
|
}
|
|
162
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
|
+
|
|
163
337
|
function position(bot) {
|
|
164
338
|
const at = bot.entity?.position;
|
|
165
339
|
return at ? { x: at.x, y: at.y, z: at.z } : { x: 0, y: 0, z: 0 };
|
package/runner/src/dispatch.mjs
CHANGED
|
@@ -30,7 +30,8 @@ export class Dispatch {
|
|
|
30
30
|
try {
|
|
31
31
|
return await this.#run(verb, command);
|
|
32
32
|
} catch (error) {
|
|
33
|
-
|
|
33
|
+
// A newline in the message would become a second stdout line, read as the next reply.
|
|
34
|
+
return protocol.encode(protocol.ERROR, verb, protocol.sanitize(error?.message ?? error));
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -38,7 +39,9 @@ export class Dispatch {
|
|
|
38
39
|
switch (verb) {
|
|
39
40
|
case protocol.SPAWN: {
|
|
40
41
|
const clientIp = command.length > 2 ? command[2] : '';
|
|
41
|
-
|
|
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));
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
case protocol.DESPAWN: {
|
|
@@ -301,3 +304,15 @@ function positionLine(verb, at) {
|
|
|
301
304
|
protocol.javaDouble(at.z),
|
|
302
305
|
);
|
|
303
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
|
+
}
|