vitaminmcp 3.1.0 → 3.2.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 +22 -0
- package/bin/vitaminmcp.mjs +25 -11
- package/checksums.json +7 -7
- package/lib/java.mjs +18 -0
- package/lib/service.mjs +82 -0
- package/package.json +4 -2
- package/runner/src/bots.mjs +185 -13
- package/runner/src/dispatch.mjs +15 -1
- package/service/windows-service.ps1 +383 -0
package/README.md
CHANGED
|
@@ -36,6 +36,28 @@ as — `/mcp` lists it); in any client, just ask:
|
|
|
36
36
|
Once the plugin is running, `session_start` needs no arguments for a server on this machine: the
|
|
37
37
|
agent leaves its host, ports and token where this server reads them.
|
|
38
38
|
|
|
39
|
+
## One process for many projects
|
|
40
|
+
|
|
41
|
+
If a Windows client keeps one stdio server per loaded project, install one shared service:
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
npx -y vitaminmcp service install
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Configure those projects with the URL `http://127.0.0.1:25584/mcp`. Each client gets isolated MCP
|
|
48
|
+
state, and bot runners are still created only after `session_start`. Windows asks for administrator
|
|
49
|
+
approval once; no client startup hook or Windows account password is needed. `service status` and
|
|
50
|
+
`service uninstall` inspect or remove it, and installing again updates it. Account data is kept on
|
|
51
|
+
uninstall.
|
|
52
|
+
|
|
53
|
+
Elsewhere, a platform service manager can own `npx -y vitaminmcp --http 25584` as a foreground
|
|
54
|
+
process. The portable default remains stdio.
|
|
55
|
+
|
|
56
|
+
The shared endpoint is loopback-only but has no token: every program and every account on the
|
|
57
|
+
machine can use the agent handshakes and Microsoft accounts under `~/.vitaminmcp` through it. Use
|
|
58
|
+
it on a computer only you use, and keep to stdio on a shared one. A session left idle for six
|
|
59
|
+
hours is closed and its bots disconnect.
|
|
60
|
+
|
|
39
61
|
## What you get
|
|
40
62
|
|
|
41
63
|
- Spawn and control test players — real protocol clients, not mock `Player` objects
|
package/bin/vitaminmcp.mjs
CHANGED
|
@@ -9,8 +9,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import {
|
|
10
10
|
MCP_SERVER_JAR, assetPath, ensureAsset, ensureJar,
|
|
11
11
|
} from '../lib/jars.mjs';
|
|
12
|
-
import { checkJava, findJava } from '../lib/java.mjs';
|
|
12
|
+
import { checkJava, findJava, mcpServerArgs } from '../lib/java.mjs';
|
|
13
13
|
import { checkNode, findNode, runnerAssetName, viewerAssetName } from '../lib/node.mjs';
|
|
14
|
+
import { runServiceAction, serviceAction } from '../lib/service.mjs';
|
|
14
15
|
|
|
15
16
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
17
|
|
|
@@ -45,6 +46,9 @@ Options
|
|
|
45
46
|
--help this text
|
|
46
47
|
--version the version this launcher will run
|
|
47
48
|
--jars download the jars and print where they are, without starting anything
|
|
49
|
+
--http [port] serve MCP over loopback HTTP (default port 25584)
|
|
50
|
+
service install|uninstall|status
|
|
51
|
+
manage one shared Windows service (install also updates it)
|
|
48
52
|
|
|
49
53
|
Environment
|
|
50
54
|
JAVA_HOME the JDK to run the jars with; Java 21 or later
|
|
@@ -69,6 +73,17 @@ async function main() {
|
|
|
69
73
|
return 0;
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
const service = serviceAction(argv);
|
|
77
|
+
if (service) {
|
|
78
|
+
if (service === 'install') {
|
|
79
|
+
const java = findJava();
|
|
80
|
+
const usable = checkJava(java);
|
|
81
|
+
if (!usable.ok) die(usable.message);
|
|
82
|
+
return await runServiceAction(service, release, { java });
|
|
83
|
+
}
|
|
84
|
+
return await runServiceAction(service, release);
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
const java = findJava();
|
|
73
88
|
const usable = checkJava(java);
|
|
74
89
|
if (!usable.ok) {
|
|
@@ -121,17 +136,11 @@ async function main() {
|
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
await runnerReady;
|
|
124
|
-
|
|
139
|
+
const env = serverEnvironment(runner, release, viewerAsset);
|
|
140
|
+
return await run(java, server, env, argv);
|
|
125
141
|
}
|
|
126
142
|
|
|
127
|
-
|
|
128
|
-
* Runs the server jar, and lives exactly as long as it does.
|
|
129
|
-
*
|
|
130
|
-
* A download still in flight is abandoned when the server exits rather than held on to: the
|
|
131
|
-
* `.part` file it leaves is claimed again by the next start, and a client waiting on a process
|
|
132
|
-
* that no longer serves anything is worse than a jar fetched twice.
|
|
133
|
-
*/
|
|
134
|
-
function run(java, server, runner, release, viewerAsset) {
|
|
143
|
+
function serverEnvironment(runner, release, viewerAsset) {
|
|
135
144
|
const env = {
|
|
136
145
|
...process.env,
|
|
137
146
|
VITAMINMCP_RUNNER_JAR: runner,
|
|
@@ -142,7 +151,12 @@ function run(java, server, runner, release, viewerAsset) {
|
|
|
142
151
|
if (!env.VITAMINMCP_VIEWER_PATH) {
|
|
143
152
|
env.VITAMINMCP_VIEWER_PATH = path.join(HERE, '..', 'lib', 'viewer-loader.mjs');
|
|
144
153
|
}
|
|
145
|
-
|
|
154
|
+
return env;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Runs the server jar, and lives exactly as long as it does. */
|
|
158
|
+
function run(java, server, env, args) {
|
|
159
|
+
const child = spawn(java, mcpServerArgs(server, args), {
|
|
146
160
|
stdio: 'inherit',
|
|
147
161
|
env,
|
|
148
162
|
});
|
package/checksums.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.
|
|
2
|
+
"version": "3.2.0",
|
|
3
3
|
"jars": {
|
|
4
|
-
"mcp-server.jar": "
|
|
4
|
+
"mcp-server.jar": "19e54259d2afb5171b6af61fb9b5e8dc41178807eef9886b0105568cab26cab6"
|
|
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": "e79b3e4a3e5ce571d5f18f199265ef2bf2de4b94102fc537c48055687d93c501",
|
|
8
|
+
"bot-runner-linux-x64": "75ec38cbd3facd07734ab147ef3ac7feae4bcfa909b39e5d2baf422b5e52c044",
|
|
9
|
+
"bot-runner-linux-arm64": "f9f35216e07ff75f3f27729068af3afd40da1fb1b5126a92cfab512ae7fb8631",
|
|
10
|
+
"bot-runner-darwin-x64": "15116f74ac073944683de21c0ee4e91f87ad079edfeed12121c54547f84ac859",
|
|
11
|
+
"bot-runner-darwin-arm64": "bd71e5b21f5a33f81411f99a7137abb0663f355f6ef54e17504bb6087b965fc6",
|
|
12
12
|
"bot-runner-viewer-win-x64.tgz": "e39a36cdf92b9223aa7c9610f8231afc7a577e8ed007bc3571895bd945baf3e1"
|
|
13
13
|
}
|
|
14
14
|
}
|
package/lib/java.mjs
CHANGED
|
@@ -5,6 +5,24 @@ import path from 'node:path';
|
|
|
5
5
|
/** The floor the jars are compiled against. Below it they will not load at all. */
|
|
6
6
|
export const REQUIRED_JAVA = 21;
|
|
7
7
|
|
|
8
|
+
const SHARED_SERVER_JVM_FLAGS = ['-Xms16m', '-Xmx128m', '-XX:+UseSerialGC'];
|
|
9
|
+
|
|
10
|
+
export function mcpServerArgs(server, args = []) {
|
|
11
|
+
const flags = args.includes('--http') ? SHARED_SERVER_JVM_FLAGS : [];
|
|
12
|
+
return [...flags, '-jar', server, ...args];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function javaHome(java, run = spawnSync) {
|
|
16
|
+
const probe = run(
|
|
17
|
+
java,
|
|
18
|
+
['-Dstdout.encoding=UTF-8', '-Dstderr.encoding=UTF-8', '-XshowSettings:properties', '-version'],
|
|
19
|
+
{ encoding: 'utf8' },
|
|
20
|
+
);
|
|
21
|
+
const output = `${probe.stderr || ''}${probe.stdout || ''}`;
|
|
22
|
+
const match = output.match(/^\s*java\.home = (.+?)\s*$/m);
|
|
23
|
+
return match ? match[1] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
/**
|
|
9
27
|
* The java to run the jars with: JAVA_HOME if it points at one, otherwise whatever is on PATH.
|
|
10
28
|
*
|
package/lib/service.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { javaHome as findJavaHome } from './java.mjs';
|
|
8
|
+
|
|
9
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const ACTIONS = new Set(['install', 'uninstall', 'status']);
|
|
11
|
+
|
|
12
|
+
export function serviceAction(args) {
|
|
13
|
+
if (args[0] !== 'service') return null;
|
|
14
|
+
if (args.length !== 2 || !ACTIONS.has(args[1])) {
|
|
15
|
+
throw new Error('service requires one action: install, uninstall, or status');
|
|
16
|
+
}
|
|
17
|
+
return args[1];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Runs the Windows-only installer kept outside the cross-platform launcher logic. */
|
|
21
|
+
export function runServiceAction(
|
|
22
|
+
action,
|
|
23
|
+
release,
|
|
24
|
+
{
|
|
25
|
+
platform = process.platform,
|
|
26
|
+
node = process.execPath,
|
|
27
|
+
java = null,
|
|
28
|
+
javaHome = java ? findJavaHome(java) : null,
|
|
29
|
+
npmCli = npmCliPath(node),
|
|
30
|
+
packageRoot = path.join(HERE, '..'),
|
|
31
|
+
home = process.env.VITAMINMCP_HOME ?? path.join(os.homedir(), '.vitaminmcp'),
|
|
32
|
+
spawnProcess = spawn,
|
|
33
|
+
} = {},
|
|
34
|
+
) {
|
|
35
|
+
if (platform !== 'win32') {
|
|
36
|
+
throw new Error('VitaminMCP service management is available only on Windows');
|
|
37
|
+
}
|
|
38
|
+
if (action === 'install' && !npmCli) {
|
|
39
|
+
throw new Error('service install needs npm; run it through npx or install Node with npm');
|
|
40
|
+
}
|
|
41
|
+
if (action === 'install' && !javaHome) {
|
|
42
|
+
throw new Error(`service install could not find the Java home of ${java ?? 'java'}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const script = path.join(HERE, '..', 'service', 'windows-service.ps1');
|
|
46
|
+
const args = [
|
|
47
|
+
'-NoProfile',
|
|
48
|
+
'-ExecutionPolicy', 'Bypass',
|
|
49
|
+
'-File', script,
|
|
50
|
+
'-Action', action,
|
|
51
|
+
'-PackageRoot', packageRoot,
|
|
52
|
+
'-NodePath', node,
|
|
53
|
+
'-DataHome', home,
|
|
54
|
+
'-Version', release,
|
|
55
|
+
];
|
|
56
|
+
if (npmCli) args.push('-NpmCliPath', npmCli);
|
|
57
|
+
if (javaHome) args.push('-JavaHome', javaHome);
|
|
58
|
+
|
|
59
|
+
const localJar = path.join(packageRoot, '..', 'build', 'dist', 'mcp-server.jar');
|
|
60
|
+
if (!existsSync(path.join(packageRoot, 'checksums.json')) && existsSync(localJar)) {
|
|
61
|
+
args.push('-ServerJar', localJar);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const child = spawnProcess('powershell.exe', args, { stdio: 'inherit' });
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
child.once('error', reject);
|
|
67
|
+
child.once('exit', (code, signal) => resolve(signal ? 1 : (code ?? 1)));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function npmCliPath(node) {
|
|
72
|
+
if (process.env.npm_execpath && existsSync(process.env.npm_execpath)) {
|
|
73
|
+
return process.env.npm_execpath;
|
|
74
|
+
}
|
|
75
|
+
const besideNode = path.join(path.dirname(node), 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
76
|
+
if (existsSync(besideNode)) return besideNode;
|
|
77
|
+
|
|
78
|
+
const found = spawnSync('where.exe', ['npm.cmd'], { encoding: 'utf8' });
|
|
79
|
+
if (found.status !== 0) return null;
|
|
80
|
+
const npm = found.stdout.split(/\r?\n/, 1)[0]?.trim();
|
|
81
|
+
return npm || null;
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vitaminmcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.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": [
|
|
@@ -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"
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"files": [
|
|
37
38
|
"bin",
|
|
38
39
|
"lib",
|
|
40
|
+
"service",
|
|
39
41
|
"runner",
|
|
40
42
|
"checksums.json",
|
|
41
43
|
"README.md"
|
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,24 +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,
|
|
156
212
|
// mineflayer's default console.logs bot errors, and stdout is the protocol channel.
|
|
157
213
|
logErrors: false,
|
|
158
214
|
};
|
|
159
|
-
if (
|
|
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 };
|
package/runner/src/dispatch.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory = $true)]
|
|
3
|
+
[ValidateSet('install', 'uninstall', 'status')]
|
|
4
|
+
[string]$Action,
|
|
5
|
+
|
|
6
|
+
[string]$PackageRoot = '',
|
|
7
|
+
[string]$NodePath = '',
|
|
8
|
+
[string]$NpmCliPath = '',
|
|
9
|
+
[string]$JavaHome = '',
|
|
10
|
+
[string]$DataHome = '',
|
|
11
|
+
[string]$Version = '',
|
|
12
|
+
[string]$ServerJar = '',
|
|
13
|
+
[string]$OutputPath = ''
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
Set-StrictMode -Version Latest
|
|
17
|
+
$ErrorActionPreference = 'Stop'
|
|
18
|
+
|
|
19
|
+
$ServiceId = 'VitaminMCP'
|
|
20
|
+
$InstallRoot = [System.IO.Path]::GetFullPath((Join-Path $env:ProgramData $ServiceId))
|
|
21
|
+
$ExpectedRoot = [System.IO.Path]::GetFullPath((Join-Path $env:ProgramData 'VitaminMCP'))
|
|
22
|
+
$Wrapper = Join-Path $InstallRoot 'VitaminMCP.exe'
|
|
23
|
+
$Config = Join-Path $InstallRoot 'VitaminMCP.xml'
|
|
24
|
+
$AppRoot = Join-Path $InstallRoot 'app'
|
|
25
|
+
$Logs = Join-Path $InstallRoot 'logs'
|
|
26
|
+
$WinSwUrl = 'https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe'
|
|
27
|
+
$WinSwSha256 = 'b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f'
|
|
28
|
+
$SystemSid = 'S-1-5-18'
|
|
29
|
+
$AdministratorsSid = 'S-1-5-32-544'
|
|
30
|
+
$WorldSids = @('S-1-1-0', 'S-1-5-11', 'S-1-5-32-545')
|
|
31
|
+
|
|
32
|
+
function Test-Administrator {
|
|
33
|
+
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
34
|
+
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
|
35
|
+
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Quote-PowerShell([string]$Value) {
|
|
39
|
+
return "'" + $Value.Replace("'", "''") + "'"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Invoke-Elevated {
|
|
43
|
+
$output = [System.IO.Path]::GetTempFileName()
|
|
44
|
+
try {
|
|
45
|
+
$values = @{
|
|
46
|
+
Action = $Action
|
|
47
|
+
PackageRoot = $PackageRoot
|
|
48
|
+
NodePath = $NodePath
|
|
49
|
+
NpmCliPath = $NpmCliPath
|
|
50
|
+
JavaHome = $JavaHome
|
|
51
|
+
DataHome = $DataHome
|
|
52
|
+
Version = $Version
|
|
53
|
+
ServerJar = $ServerJar
|
|
54
|
+
OutputPath = $output
|
|
55
|
+
}
|
|
56
|
+
$command = '& ' + (Quote-PowerShell $PSCommandPath)
|
|
57
|
+
foreach ($entry in $values.GetEnumerator()) {
|
|
58
|
+
$command += ' -' + $entry.Key + ' ' + (Quote-PowerShell ([string]$entry.Value))
|
|
59
|
+
}
|
|
60
|
+
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
|
|
61
|
+
Write-Host 'Waiting for administrator approval, then for the service change to finish...'
|
|
62
|
+
try {
|
|
63
|
+
$process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru `
|
|
64
|
+
-WindowStyle Hidden `
|
|
65
|
+
-ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $encoded)
|
|
66
|
+
} catch {
|
|
67
|
+
throw 'Administrator approval is required to change the VitaminMCP service.'
|
|
68
|
+
}
|
|
69
|
+
Get-Content -LiteralPath $output | Write-Host
|
|
70
|
+
$code = $process.ExitCode
|
|
71
|
+
} finally {
|
|
72
|
+
Remove-Item -LiteralPath $output -Force -ErrorAction SilentlyContinue
|
|
73
|
+
}
|
|
74
|
+
exit $code
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function Invoke-Native([string]$File, [string[]]$Arguments) {
|
|
78
|
+
$ErrorActionPreference = 'Continue'
|
|
79
|
+
& $File @Arguments 2>&1 | ForEach-Object { "$_" }
|
|
80
|
+
if ($LASTEXITCODE -ne 0) {
|
|
81
|
+
throw "$File exited with code $LASTEXITCODE"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function Initialize-InstallRoot {
|
|
86
|
+
$inherit = [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
87
|
+
$security = [Security.AccessControl.DirectorySecurity]::new()
|
|
88
|
+
$security.SetOwner([Security.Principal.SecurityIdentifier]::new($AdministratorsSid))
|
|
89
|
+
$security.SetAccessRuleProtection($true, $false)
|
|
90
|
+
foreach ($sid in @($SystemSid, $AdministratorsSid)) {
|
|
91
|
+
$security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
|
92
|
+
[Security.Principal.SecurityIdentifier]::new($sid), 'FullControl', $inherit, 'None', 'Allow'))
|
|
93
|
+
}
|
|
94
|
+
[System.IO.Directory]::CreateDirectory($InstallRoot, $security) | Out-Null
|
|
95
|
+
|
|
96
|
+
$directory = Get-Item -LiteralPath $InstallRoot -Force
|
|
97
|
+
$existing = Get-Acl -LiteralPath $InstallRoot
|
|
98
|
+
$owner = $existing.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
99
|
+
$reparse = [bool]($directory.Attributes -band [System.IO.FileAttributes]::ReparsePoint)
|
|
100
|
+
if ($reparse -or $owner -ne $AdministratorsSid -or -not $existing.AreAccessRulesProtected) {
|
|
101
|
+
throw "$InstallRoot already exists and was not created by this installer. " +
|
|
102
|
+
'Remove it as an administrator and run the install again.'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function Test-WorldReadable([string]$Path) {
|
|
107
|
+
$read = [Security.AccessControl.FileSystemRights]::ReadAndExecute
|
|
108
|
+
$rules = (Get-Acl -LiteralPath $Path).GetAccessRules(
|
|
109
|
+
$true, $true, [Security.Principal.SecurityIdentifier])
|
|
110
|
+
foreach ($rule in $rules) {
|
|
111
|
+
if ($rule.AccessControlType -eq 'Allow' -and `
|
|
112
|
+
$WorldSids -contains $rule.IdentityReference.Value -and `
|
|
113
|
+
($rule.FileSystemRights -band $read) -eq $read) {
|
|
114
|
+
return $true
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return $false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function Grant-ServiceRead([string]$Path, [string]$Rights) {
|
|
121
|
+
if (Test-WorldReadable $Path) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
Invoke-Native 'icacls.exe' @($Path, '/grant', "NT SERVICE\${ServiceId}:$Rights", '/T', '/C', '/Q')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function Get-LogTail {
|
|
128
|
+
$files = Get-ChildItem -LiteralPath $Logs -Filter '*.log' -File -ErrorAction SilentlyContinue
|
|
129
|
+
foreach ($file in $files) {
|
|
130
|
+
Write-Output "--- $($file.Name)"
|
|
131
|
+
Get-Content -LiteralPath $file.FullName -Tail 20
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function Escape-Xml([string]$Value) {
|
|
136
|
+
return [Security.SecurityElement]::Escape($Value)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function Get-InstalledService {
|
|
140
|
+
return Get-CimInstance Win32_Service -Filter "Name='$ServiceId'" -ErrorAction SilentlyContinue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function Assert-OwnedService {
|
|
144
|
+
$service = Get-InstalledService
|
|
145
|
+
if ($null -eq $service) {
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
$registered = [System.IO.Path]::GetFullPath($service.PathName.Trim('"'))
|
|
149
|
+
if (-not $registered.Equals($Wrapper, [StringComparison]::OrdinalIgnoreCase)) {
|
|
150
|
+
throw "A different service named $ServiceId already exists at $registered"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function Test-Mcp {
|
|
155
|
+
$session = $null
|
|
156
|
+
try {
|
|
157
|
+
$body = @{
|
|
158
|
+
jsonrpc = '2.0'
|
|
159
|
+
id = 1
|
|
160
|
+
method = 'initialize'
|
|
161
|
+
params = @{
|
|
162
|
+
protocolVersion = '2025-06-18'
|
|
163
|
+
capabilities = @{}
|
|
164
|
+
clientInfo = @{ name = 'vitaminmcp-service'; version = '1' }
|
|
165
|
+
}
|
|
166
|
+
} | ConvertTo-Json -Depth 5 -Compress
|
|
167
|
+
$response = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 `
|
|
168
|
+
-Uri 'http://127.0.0.1:25584/mcp' -Method Post `
|
|
169
|
+
-ContentType 'application/json' -Headers @{ Accept = 'application/json, text/event-stream' } `
|
|
170
|
+
-Body $body
|
|
171
|
+
$session = $response.Headers['Mcp-Session-Id']
|
|
172
|
+
$json = $response.Content | ConvertFrom-Json
|
|
173
|
+
return $response.StatusCode -eq 200 -and $session -and `
|
|
174
|
+
$json.result.serverInfo.name -eq 'VitaminMCP'
|
|
175
|
+
} catch {
|
|
176
|
+
return $false
|
|
177
|
+
} finally {
|
|
178
|
+
if ($session) {
|
|
179
|
+
try {
|
|
180
|
+
Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 `
|
|
181
|
+
-Uri 'http://127.0.0.1:25584/mcp' -Method Delete `
|
|
182
|
+
-Headers @{ 'Mcp-Protocol-Version' = '2025-06-18'; 'Mcp-Session-Id' = $session } `
|
|
183
|
+
| Out-Null
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function Show-Status {
|
|
190
|
+
$service = Get-Service -Name $ServiceId -ErrorAction SilentlyContinue
|
|
191
|
+
if ($null -eq $service) {
|
|
192
|
+
Write-Output 'VitaminMCP service is not installed.'
|
|
193
|
+
exit 1
|
|
194
|
+
}
|
|
195
|
+
$ready = Test-Mcp
|
|
196
|
+
Write-Output "VitaminMCP service: $($service.Status); MCP ready: $ready"
|
|
197
|
+
if (-not $ready) {
|
|
198
|
+
exit 1
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function Install-Service {
|
|
203
|
+
if (-not $PackageRoot -or -not $NodePath -or -not $NpmCliPath -or `
|
|
204
|
+
-not $JavaHome -or -not $DataHome -or -not $Version) {
|
|
205
|
+
throw 'The service installer did not receive complete runtime paths.'
|
|
206
|
+
}
|
|
207
|
+
$javaPath = Join-Path $JavaHome 'bin\java.exe'
|
|
208
|
+
foreach ($required in @($PackageRoot, $NodePath, $NpmCliPath, $javaPath)) {
|
|
209
|
+
if (-not (Test-Path -LiteralPath $required)) {
|
|
210
|
+
throw "Required path does not exist: $required"
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if ($ServerJar -and -not (Test-Path -LiteralPath $ServerJar -PathType Leaf)) {
|
|
214
|
+
throw "Local MCP server jar does not exist: $ServerJar"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
Initialize-InstallRoot
|
|
218
|
+
New-Item -ItemType Directory -Force -Path $DataHome, $Logs | Out-Null
|
|
219
|
+
|
|
220
|
+
$download = "$Wrapper.part"
|
|
221
|
+
$validWrapper = (Test-Path -LiteralPath $Wrapper -PathType Leaf) -and `
|
|
222
|
+
((Get-FileHash -Algorithm SHA256 -LiteralPath $Wrapper).Hash.ToLowerInvariant() -eq $WinSwSha256)
|
|
223
|
+
if (-not $validWrapper) {
|
|
224
|
+
Remove-Item -LiteralPath $download -Force -ErrorAction SilentlyContinue
|
|
225
|
+
Invoke-WebRequest -UseBasicParsing -Uri $WinSwUrl -OutFile $download
|
|
226
|
+
$received = (Get-FileHash -Algorithm SHA256 -LiteralPath $download).Hash.ToLowerInvariant()
|
|
227
|
+
if ($received -ne $WinSwSha256) {
|
|
228
|
+
Remove-Item -LiteralPath $download -Force
|
|
229
|
+
throw "WinSW checksum mismatch: expected $WinSwSha256, received $received"
|
|
230
|
+
}
|
|
231
|
+
Move-Item -LiteralPath $download -Destination $Wrapper -Force
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
$staging = Join-Path $InstallRoot 'app.new'
|
|
235
|
+
if (Test-Path -LiteralPath $staging) {
|
|
236
|
+
Remove-Item -LiteralPath $staging -Recurse -Force
|
|
237
|
+
}
|
|
238
|
+
New-Item -ItemType Directory -Path $staging | Out-Null
|
|
239
|
+
$npmArguments = @(
|
|
240
|
+
'install', '--omit=dev', '--ignore-scripts', '--install-links', '--no-save',
|
|
241
|
+
'--prefix', $staging, $PackageRoot
|
|
242
|
+
)
|
|
243
|
+
if ($NpmCliPath.EndsWith('.cmd', [StringComparison]::OrdinalIgnoreCase)) {
|
|
244
|
+
Invoke-Native $NpmCliPath $npmArguments
|
|
245
|
+
} else {
|
|
246
|
+
Invoke-Native $NodePath (@($NpmCliPath) + $npmArguments)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
$launcher = Join-Path $staging 'node_modules\vitaminmcp\bin\vitaminmcp.mjs'
|
|
250
|
+
if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) {
|
|
251
|
+
throw "npm did not install the VitaminMCP launcher at $launcher"
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
Assert-OwnedService
|
|
255
|
+
$service = Get-Service -Name $ServiceId -ErrorAction SilentlyContinue
|
|
256
|
+
if ($service -and $service.Status -ne 'Stopped') {
|
|
257
|
+
Stop-Service -Name $ServiceId -Force
|
|
258
|
+
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (Test-Path -LiteralPath $AppRoot) {
|
|
262
|
+
Remove-Item -LiteralPath $AppRoot -Recurse -Force
|
|
263
|
+
}
|
|
264
|
+
Move-Item -LiteralPath $staging -Destination $AppRoot
|
|
265
|
+
$launcher = Join-Path $AppRoot 'node_modules\vitaminmcp\bin\vitaminmcp.mjs'
|
|
266
|
+
|
|
267
|
+
$localJar = ''
|
|
268
|
+
if ($ServerJar) {
|
|
269
|
+
$localJar = Join-Path $InstallRoot 'mcp-server.jar'
|
|
270
|
+
Copy-Item -LiteralPath $ServerJar -Destination $localJar -Force
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
$javaDirectory = Split-Path -Parent $javaPath
|
|
274
|
+
$accounts = Join-Path $DataHome 'accounts'
|
|
275
|
+
$serverEnvironment = if ($localJar) {
|
|
276
|
+
" <env name=`"VITAMINMCP_SERVER_JAR`" value=`"$(Escape-Xml $localJar)`" />`r`n"
|
|
277
|
+
} else { '' }
|
|
278
|
+
$xml = @"
|
|
279
|
+
<service>
|
|
280
|
+
<id>$ServiceId</id>
|
|
281
|
+
<name>VitaminMCP</name>
|
|
282
|
+
<description>Shared local VitaminMCP server for MCP clients.</description>
|
|
283
|
+
<executable>$(Escape-Xml $NodePath)</executable>
|
|
284
|
+
<arguments>"$(Escape-Xml $launcher)" --http 25584</arguments>
|
|
285
|
+
<workingdirectory>$(Escape-Xml $AppRoot)</workingdirectory>
|
|
286
|
+
<env name="VITAMINMCP_HOME" value="$(Escape-Xml $DataHome)" />
|
|
287
|
+
<env name="VITAMINMCP_ACCOUNTS_DIR" value="$(Escape-Xml $accounts)" />
|
|
288
|
+
<env name="VITAMINMCP_NODE" value="$(Escape-Xml $NodePath)" />
|
|
289
|
+
<env name="JAVA_HOME" value="$(Escape-Xml $JavaHome)" />
|
|
290
|
+
<env name="PATH" value="$(Escape-Xml $javaDirectory);%PATH%" />
|
|
291
|
+
$serverEnvironment <startmode>Automatic</startmode>
|
|
292
|
+
<delayedAutoStart>true</delayedAutoStart>
|
|
293
|
+
<onfailure action="restart" delay="10 sec" />
|
|
294
|
+
<resetfailure>1 hour</resetfailure>
|
|
295
|
+
<stoptimeout>15 sec</stoptimeout>
|
|
296
|
+
<logpath>$(Escape-Xml $Logs)</logpath>
|
|
297
|
+
<log mode="roll" />
|
|
298
|
+
</service>
|
|
299
|
+
"@
|
|
300
|
+
[System.IO.File]::WriteAllText($Config, $xml, [Text.UTF8Encoding]::new($false))
|
|
301
|
+
|
|
302
|
+
if (-not (Get-Service -Name $ServiceId -ErrorAction SilentlyContinue)) {
|
|
303
|
+
Invoke-Native $Wrapper @('install')
|
|
304
|
+
}
|
|
305
|
+
Invoke-Native 'sc.exe' @('config', $ServiceId, 'obj=', "NT SERVICE\$ServiceId", 'start=', 'delayed-auto')
|
|
306
|
+
Invoke-Native 'sc.exe' @('sidtype', $ServiceId, 'unrestricted')
|
|
307
|
+
|
|
308
|
+
$principal = "NT SERVICE\$ServiceId"
|
|
309
|
+
Invoke-Native 'icacls.exe' @($InstallRoot, '/grant', "${principal}:(OI)(CI)RX", '/T', '/C', '/Q')
|
|
310
|
+
Invoke-Native 'icacls.exe' @($Logs, '/grant', "${principal}:(OI)(CI)M", '/T', '/C', '/Q')
|
|
311
|
+
Invoke-Native 'icacls.exe' @($DataHome, '/grant', "${principal}:(OI)(CI)M", '/T', '/C', '/Q')
|
|
312
|
+
Grant-ServiceRead $NodePath 'RX'
|
|
313
|
+
Grant-ServiceRead $JavaHome '(OI)(CI)RX'
|
|
314
|
+
|
|
315
|
+
Start-Service -Name $ServiceId
|
|
316
|
+
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
|
317
|
+
do {
|
|
318
|
+
if (Test-Mcp) {
|
|
319
|
+
Write-Output "VitaminMCP $Version is ready at http://127.0.0.1:25584/mcp"
|
|
320
|
+
Write-Output ''
|
|
321
|
+
Write-Output 'Codex config:'
|
|
322
|
+
Write-Output '[mcp_servers.vitaminmcp]'
|
|
323
|
+
Write-Output 'url = "http://127.0.0.1:25584/mcp"'
|
|
324
|
+
Write-Output 'startup_timeout_sec = 60'
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
Start-Sleep -Milliseconds 250
|
|
328
|
+
} while ([DateTime]::UtcNow -lt $deadline)
|
|
329
|
+
Get-LogTail
|
|
330
|
+
throw "The service started but MCP did not become ready. The log tails above are from $Logs"
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function Uninstall-Service {
|
|
334
|
+
Assert-OwnedService
|
|
335
|
+
$service = Get-Service -Name $ServiceId -ErrorAction SilentlyContinue
|
|
336
|
+
if ($service) {
|
|
337
|
+
if ($service.Status -ne 'Stopped') {
|
|
338
|
+
Stop-Service -Name $ServiceId -Force
|
|
339
|
+
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30))
|
|
340
|
+
}
|
|
341
|
+
Invoke-Native 'sc.exe' @('delete', $ServiceId)
|
|
342
|
+
$deadline = [DateTime]::UtcNow.AddSeconds(15)
|
|
343
|
+
while ((Get-Service -Name $ServiceId -ErrorAction SilentlyContinue) -and `
|
|
344
|
+
[DateTime]::UtcNow -lt $deadline) {
|
|
345
|
+
Start-Sleep -Milliseconds 250
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (-not $InstallRoot.Equals($ExpectedRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
|
349
|
+
throw "Refusing to remove unexpected install path: $InstallRoot"
|
|
350
|
+
}
|
|
351
|
+
if (Test-Path -LiteralPath $InstallRoot) {
|
|
352
|
+
Remove-Item -LiteralPath $InstallRoot -Recurse -Force
|
|
353
|
+
}
|
|
354
|
+
Write-Output "VitaminMCP service removed. Account data was kept at $DataHome"
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if ($Action -eq 'status') {
|
|
358
|
+
Show-Status
|
|
359
|
+
exit 0
|
|
360
|
+
}
|
|
361
|
+
if (-not (Test-Administrator)) {
|
|
362
|
+
Invoke-Elevated
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function Invoke-Action {
|
|
366
|
+
if ($Action -eq 'install') {
|
|
367
|
+
Install-Service
|
|
368
|
+
} else {
|
|
369
|
+
Uninstall-Service
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (-not $OutputPath) {
|
|
374
|
+
Invoke-Action
|
|
375
|
+
exit 0
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
Invoke-Action *> $OutputPath
|
|
379
|
+
} catch {
|
|
380
|
+
"ERROR: $($_.Exception.Message)" | Out-File -LiteralPath $OutputPath -Append
|
|
381
|
+
exit 1
|
|
382
|
+
}
|
|
383
|
+
exit 0
|