vitaminmcp 3.1.1 → 3.2.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 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
@@ -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
- return await run(java, server, runner, release, viewerAsset);
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
- const child = spawn(java, ['-jar', server], {
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.1.1",
2
+ "version": "3.2.1",
3
3
  "jars": {
4
- "mcp-server.jar": "423c885926a6c5f82d1c403ce01592afabfcc0646b756c5ce7370a0a88be09b1"
4
+ "mcp-server.jar": "b9af85303677bfe1e792259c5efab2763bd0fbafa12c1d468f7b034c563ba021"
5
5
  },
6
6
  "assets": {
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",
7
+ "bot-runner-win-x64.exe": "0932bb6677cf9ff8657905a193e8f36f2943a4bba2b1597817f7ac192b575524",
8
+ "bot-runner-linux-x64": "a488c2f97c8c63f46bfb40e192af6939d3f49af1c61426974eb36cc3688a4db4",
9
+ "bot-runner-linux-arm64": "492fb73d79fd8db5d391567eb5b093c8f48bbd39b9bd669061c30c10dafc829f",
10
+ "bot-runner-darwin-x64": "b5fa212b25d001938384015fb22d5168977974d38105746b2c3a7a2b73aabd09",
11
+ "bot-runner-darwin-arm64": "e426feeac28baea547f5010ac47a33f50c401419bce258c4492408a87eec83bd",
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
  *
@@ -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.1.1",
3
+ "version": "3.2.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": [
@@ -37,6 +37,7 @@
37
37
  "files": [
38
38
  "bin",
39
39
  "lib",
40
+ "service",
40
41
  "runner",
41
42
  "checksums.json",
42
43
  "README.md"
package/runner/runner.mjs CHANGED
@@ -6,6 +6,11 @@
6
6
  *
7
7
  * node runner.mjs <host> <port> [protocol]
8
8
  *
9
+ * Without a protocol it pings the server. VITAMINMCP_SERVER_VERSION, when set, is the Minecraft
10
+ * version the agent reports, and it wins over a ping that disagrees (see `selectProtocol`). It is
11
+ * an environment variable rather than an argument so a runner and an MCP server of different
12
+ * releases still understand each other's command line.
13
+ *
9
14
  * Everything it prints on stdout is protocol. Diagnostics go to stderr, which the MCP server
10
15
  * inherits — a stray `console.log` here is a desynchronised session, not a stray log line.
11
16
  */
@@ -14,7 +19,7 @@ import { createInterface } from 'node:readline';
14
19
  import { BotRegistry } from './src/bots.mjs';
15
20
  import { Dispatch } from './src/dispatch.mjs';
16
21
  import * as protocol from './src/protocol.mjs';
17
- import { pingProtocol, versionForProtocol } from './src/version.mjs';
22
+ import { pingProtocol, selectProtocol, versionForProtocol } from './src/version.mjs';
18
23
 
19
24
  // stdout is the protocol channel; a dependency's console.log (mineflayer did this on 26.1, with a
20
25
  // stack trace) desynchronises every later reply. `write` below is the only path to stdout.
@@ -32,10 +37,24 @@ async function main() {
32
37
  const host = argv[0];
33
38
  const port = Number(argv[1]);
34
39
 
40
+ const serverVersion = process.env.VITAMINMCP_SERVER_VERSION || undefined;
41
+
35
42
  let bots;
36
43
  let negotiated;
44
+ let pinged;
37
45
  try {
38
- negotiated = argv.length > 2 ? Number(argv[2]) : await pingProtocol(host, port);
46
+ if (argv.length > 2) {
47
+ negotiated = Number(argv[2]);
48
+ } else {
49
+ ({ protocol: negotiated, pinged } = selectProtocol(await pingProtocol(host, port), serverVersion));
50
+ }
51
+ if (pinged !== undefined) {
52
+ process.stderr.write(
53
+ `The server-list ping answered protocol ${pinged}, but the agent reports Minecraft `
54
+ + `${serverVersion} (protocol ${negotiated}). Something in front of the server answered `
55
+ + `the ping, a proxy or ViaVersion; using ${negotiated}.\n`,
56
+ );
57
+ }
39
58
  bots = new BotRegistry(host, port, versionForProtocol(negotiated));
40
59
  } catch (error) {
41
60
  // The Java side reads this line and reports it as the reason the runner did not start, so it
@@ -45,7 +64,10 @@ async function main() {
45
64
  process.exit(1);
46
65
  }
47
66
 
48
- write(protocol.encode(protocol.READY, String(negotiated)));
67
+ // A third field only when the ping disagreed, so the line a caller already parses is unchanged.
68
+ write(pinged === undefined
69
+ ? protocol.encode(protocol.READY, String(negotiated))
70
+ : protocol.encode(protocol.READY, String(negotiated), String(pinged)));
49
71
 
50
72
  const dispatch = new Dispatch(bots);
51
73
  const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
@@ -76,3 +76,39 @@ export function versionForProtocol(protocol) {
76
76
  .join(', ')}). It supports ${mc.supportedVersions.join(', ')}.`,
77
77
  );
78
78
  }
79
+
80
+ /**
81
+ * The protocol a Minecraft version speaks, from minecraft-data's table of every release it has
82
+ * heard of. That table is wider than the versions it carries data for, so a release the runner
83
+ * cannot speak yet still has a number here.
84
+ */
85
+ export function protocolForVersion(minecraftVersion) {
86
+ return minecraftData.versionsByMinecraftVersion?.pc?.[minecraftVersion]?.version;
87
+ }
88
+
89
+ /**
90
+ * Which protocol to speak: the one the agent says its server runs, over the one the ping answered.
91
+ *
92
+ * A ping is answered by whatever holds the port, and two common things answer it with the
93
+ * protocol the ping was written in rather than the server's own: a proxy that echoes it, and
94
+ * ViaVersion with ViaBackwards, which rewrites the status reply to the client's protocol whenever
95
+ * it can translate. The ping is written in PING_VERSION, so either one made the runner join as
96
+ * 1.21.1 — on a 26.x server, through a chain of translations, without a word. The agent runs inside
97
+ * the server, and nothing answers for it.
98
+ *
99
+ * Without a version from the agent the ping decides, as it always did. `pinged` is returned only
100
+ * when the two disagree, so the caller can say so.
101
+ */
102
+ export function selectProtocol(pinged, serverVersion) {
103
+ if (!serverVersion) {
104
+ return { protocol: pinged };
105
+ }
106
+ const declared = protocolForVersion(serverVersion);
107
+ if (declared === undefined) {
108
+ throw new Error(
109
+ `The agent reports Minecraft ${serverVersion}, which this runner's minecraft-data does not `
110
+ + 'know: the dependency is older than the server.',
111
+ );
112
+ }
113
+ return declared === pinged ? { protocol: declared } : { protocol: declared, pinged };
114
+ }
@@ -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>&quot;$(Escape-Xml $launcher)&quot; --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