@agilemonster/mcp-server 1.0.24 → 1.0.26
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 +50 -10
- package/bin/mcp.js +33 -1
- package/bin/orchestrator.js +1 -0
- package/dist/agent-engine.d.ts +17 -0
- package/dist/agent-engine.js +119 -4
- package/dist/client.d.ts +17 -3
- package/dist/client.js +24 -5
- package/dist/machine.d.ts +25 -0
- package/dist/machine.js +32 -0
- package/dist/orchestrator.d.ts +2 -0
- package/dist/orchestrator.js +33 -7
- package/dist/server.d.ts +10 -1
- package/dist/server.js +83 -29
- package/dist/service.d.ts +82 -0
- package/dist/service.js +327 -0
- package/package.json +4 -1
package/dist/service.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { agileMonsterHome, exec, which } from './runtime.js';
|
|
5
|
+
/**
|
|
6
|
+
* Runs the card runner as a background service that starts with the computer.
|
|
7
|
+
*
|
|
8
|
+
* Without this, cards queued from the board are only worked while an AI coding tool happens to be open in
|
|
9
|
+
* the project folder (its MCP server pulses as a side effect). People do not know that, so their first card
|
|
10
|
+
* sits in "queued" for ever. `bootstrap` installs the service; `service install|status|uninstall` manage it.
|
|
11
|
+
*
|
|
12
|
+
* The service runs its own copy of the package from ~/.agilemonster/runner, so it does not depend on an npx
|
|
13
|
+
* cache that can be cleared or on a project folder that can move, and it updates that copy itself.
|
|
14
|
+
*
|
|
15
|
+
* macOS: a launchd agent. Linux: a systemd user unit. Windows: not yet.
|
|
16
|
+
*/
|
|
17
|
+
export const SERVICE_LABEL = 'com.agilemonster.pulse';
|
|
18
|
+
export const SYSTEMD_UNIT = 'agilemonster-runner.service';
|
|
19
|
+
const PACKAGE = '@agilemonster/mcp-server';
|
|
20
|
+
/**
|
|
21
|
+
* The node binary the service should start. `process.execPath` is often a versioned folder (Homebrew's
|
|
22
|
+
* Cellar/node@24/24.21.0_1/…) that disappears with the next upgrade and would leave the service unable to
|
|
23
|
+
* start; the `node` on PATH is a stable link to the same binary, so prefer it when it is the same one.
|
|
24
|
+
*/
|
|
25
|
+
export function stableNodePath() {
|
|
26
|
+
const onPath = which('node');
|
|
27
|
+
if (onPath) {
|
|
28
|
+
try {
|
|
29
|
+
if (fs.realpathSync(onPath) === fs.realpathSync(process.execPath))
|
|
30
|
+
return onPath;
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
}
|
|
34
|
+
return process.execPath;
|
|
35
|
+
}
|
|
36
|
+
export function defaultServiceDeps() {
|
|
37
|
+
return {
|
|
38
|
+
platform: process.platform,
|
|
39
|
+
home: os.homedir(),
|
|
40
|
+
amHome: agileMonsterHome(),
|
|
41
|
+
uid: typeof process.getuid === 'function' ? process.getuid() : null,
|
|
42
|
+
node: stableNodePath(),
|
|
43
|
+
npm: which('npm'),
|
|
44
|
+
pathEnv: process.env.PATH || '',
|
|
45
|
+
exec,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function runnerDir(amHome) {
|
|
49
|
+
return path.join(amHome, 'runner');
|
|
50
|
+
}
|
|
51
|
+
/** The service's own copy of this package's entry point. */
|
|
52
|
+
export function runnerEntry(amHome) {
|
|
53
|
+
return path.join(runnerDir(amHome), 'node_modules', '@agilemonster', 'mcp-server', 'bin', 'mcp.js');
|
|
54
|
+
}
|
|
55
|
+
export function serviceKind(platform) {
|
|
56
|
+
if (platform === 'darwin')
|
|
57
|
+
return 'launchd';
|
|
58
|
+
if (platform === 'linux')
|
|
59
|
+
return 'systemd';
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
export function unitPath(kind, home) {
|
|
63
|
+
return kind === 'launchd'
|
|
64
|
+
? path.join(home, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`)
|
|
65
|
+
: path.join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT);
|
|
66
|
+
}
|
|
67
|
+
function xml(value) {
|
|
68
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
69
|
+
}
|
|
70
|
+
export function launchdPlist(o) {
|
|
71
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
72
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
73
|
+
<plist version="1.0">
|
|
74
|
+
<dict>
|
|
75
|
+
<key>Label</key>
|
|
76
|
+
<string>${SERVICE_LABEL}</string>
|
|
77
|
+
<key>ProgramArguments</key>
|
|
78
|
+
<array>
|
|
79
|
+
<string>${xml(o.node)}</string>
|
|
80
|
+
<string>${xml(o.entry)}</string>
|
|
81
|
+
<string>pulse-daemon</string>
|
|
82
|
+
</array>
|
|
83
|
+
<key>WorkingDirectory</key>
|
|
84
|
+
<string>${xml(o.home)}</string>
|
|
85
|
+
<key>EnvironmentVariables</key>
|
|
86
|
+
<dict>
|
|
87
|
+
<key>HOME</key>
|
|
88
|
+
<string>${xml(o.home)}</string>
|
|
89
|
+
<key>PATH</key>
|
|
90
|
+
<string>${xml(o.pathEnv)}</string>
|
|
91
|
+
</dict>
|
|
92
|
+
<key>RunAtLoad</key>
|
|
93
|
+
<true/>
|
|
94
|
+
<key>KeepAlive</key>
|
|
95
|
+
<true/>
|
|
96
|
+
<key>ThrottleInterval</key>
|
|
97
|
+
<integer>15</integer>
|
|
98
|
+
<key>StandardOutPath</key>
|
|
99
|
+
<string>${xml(path.join(o.logDir, 'pulse-daemon.log'))}</string>
|
|
100
|
+
<key>StandardErrorPath</key>
|
|
101
|
+
<string>${xml(path.join(o.logDir, 'pulse-daemon.err.log'))}</string>
|
|
102
|
+
</dict>
|
|
103
|
+
</plist>
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
/** systemd splits ExecStart on spaces; a path with one must be quoted, and a literal % doubled. */
|
|
107
|
+
function systemdArg(value) {
|
|
108
|
+
const escaped = value.replace(/%/g, '%%').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
109
|
+
return /[\s"']/.test(value) ? `"${escaped}"` : escaped;
|
|
110
|
+
}
|
|
111
|
+
export function systemdUnit(o) {
|
|
112
|
+
return `[Unit]
|
|
113
|
+
Description=Agile Monster card runner
|
|
114
|
+
After=network-online.target
|
|
115
|
+
Wants=network-online.target
|
|
116
|
+
|
|
117
|
+
[Service]
|
|
118
|
+
ExecStart=${systemdArg(o.node)} ${systemdArg(o.entry)} pulse-daemon
|
|
119
|
+
WorkingDirectory=${o.home}
|
|
120
|
+
Environment=${systemdArg(`PATH=${o.pathEnv}`)}
|
|
121
|
+
Restart=always
|
|
122
|
+
RestartSec=15
|
|
123
|
+
StandardOutput=append:${path.join(o.logDir, 'pulse-daemon.log')}
|
|
124
|
+
StandardError=append:${path.join(o.logDir, 'pulse-daemon.err.log')}
|
|
125
|
+
|
|
126
|
+
[Install]
|
|
127
|
+
WantedBy=default.target
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
/** The program an existing unit file starts (the script after the node binary), or null when unreadable. */
|
|
131
|
+
export function unitTarget(kind, content) {
|
|
132
|
+
if (kind === 'launchd') {
|
|
133
|
+
const block = content.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
134
|
+
if (!block)
|
|
135
|
+
return null;
|
|
136
|
+
const args = Array.from(block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)).map((m) => m[1].replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'));
|
|
137
|
+
return args.find((a) => /mcp\.js$/.test(a)) || args[1] || args[0] || null;
|
|
138
|
+
}
|
|
139
|
+
const line = content.match(/^ExecStart=(.*)$/m);
|
|
140
|
+
if (!line)
|
|
141
|
+
return null;
|
|
142
|
+
const parts = line[1].match(/"(?:[^"\\]|\\.)*"|\S+/g) || [];
|
|
143
|
+
const clean = parts.map((p) => (p.startsWith('"') ? p.slice(1, -1).replace(/\\(["\\])/g, '$1') : p).replace(/%%/g, '%'));
|
|
144
|
+
return clean.find((a) => /mcp\.js$/.test(a)) || clean[1] || clean[0] || null;
|
|
145
|
+
}
|
|
146
|
+
export async function serviceStatus(deps = defaultServiceDeps()) {
|
|
147
|
+
const kind = serviceKind(deps.platform);
|
|
148
|
+
if (!kind)
|
|
149
|
+
return { supported: false, kind: null, unitPath: null, installed: false, managed: false, target: null, running: false };
|
|
150
|
+
const file = unitPath(kind, deps.home);
|
|
151
|
+
let content = '';
|
|
152
|
+
try {
|
|
153
|
+
content = fs.readFileSync(file, 'utf8');
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
const installed = content !== '';
|
|
157
|
+
const target = installed ? unitTarget(kind, content) : null;
|
|
158
|
+
const managed = !!target && path.resolve(target) === path.resolve(runnerEntry(deps.amHome));
|
|
159
|
+
let running = false;
|
|
160
|
+
if (installed) {
|
|
161
|
+
if (kind === 'launchd') {
|
|
162
|
+
const res = deps.uid === null ? null : await deps.exec('launchctl', ['print', `gui/${deps.uid}/${SERVICE_LABEL}`], { timeoutMs: 15000 });
|
|
163
|
+
running = !!res && res.ok && /state = running/.test(res.stdout);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const res = await deps.exec('systemctl', ['--user', 'is-active', SYSTEMD_UNIT], { timeoutMs: 15000 });
|
|
167
|
+
running = res.stdout.trim() === 'active';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { supported: true, kind, unitPath: file, installed, managed, target, running };
|
|
171
|
+
}
|
|
172
|
+
const KEEP_OPEN_HINT = 'Until then, cards are worked while your AI coding tool is open in the project folder, or while this runs in a terminal: npx -y @agilemonster/mcp-server pulse-daemon';
|
|
173
|
+
export async function installService(options, deps = defaultServiceDeps()) {
|
|
174
|
+
const kind = serviceKind(deps.platform);
|
|
175
|
+
if (!kind) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
outcome: 'unsupported',
|
|
179
|
+
message: `The background runner cannot be installed as a service on this system yet (${deps.platform}). ${KEEP_OPEN_HINT}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (!options.hasApiKey) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
outcome: 'failed',
|
|
186
|
+
message: 'No API key is saved on this computer yet. Run `npx -y @agilemonster/mcp-server bootstrap` in your project folder first.',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const status = await serviceStatus(deps);
|
|
190
|
+
if (status.installed && !status.managed && !options.force) {
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
outcome: 'kept',
|
|
194
|
+
message: `A background runner is already set up on this computer (${status.target || status.unitPath}), so it was left as it is.` +
|
|
195
|
+
' To replace it with the standard one: npx -y @agilemonster/mcp-server service install --force',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (!deps.npm) {
|
|
199
|
+
return { ok: false, outcome: 'failed', message: `npm was not found, so the runner could not be installed. ${KEEP_OPEN_HINT}` };
|
|
200
|
+
}
|
|
201
|
+
if (kind === 'systemd') {
|
|
202
|
+
const probe = await deps.exec('systemctl', ['--user', 'show-environment'], { timeoutMs: 15000 });
|
|
203
|
+
if (!probe.ok) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
outcome: 'unsupported',
|
|
207
|
+
message: `This system has no systemd user session, so the background runner could not be installed as a service. ${KEEP_OPEN_HINT}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// 1. The service's own copy of the package
|
|
212
|
+
const dir = runnerDir(deps.amHome);
|
|
213
|
+
const entry = runnerEntry(deps.amHome);
|
|
214
|
+
const logDir = path.join(deps.amHome, 'logs');
|
|
215
|
+
try {
|
|
216
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
217
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
218
|
+
const manifest = path.join(dir, 'package.json');
|
|
219
|
+
if (!fs.existsSync(manifest)) {
|
|
220
|
+
fs.writeFileSync(manifest, JSON.stringify({ name: 'agilemonster-runner', private: true }, null, 2), 'utf8');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
return { ok: false, outcome: 'failed', message: `Could not create ${dir}: ${err instanceof Error ? err.message : String(err)}` };
|
|
225
|
+
}
|
|
226
|
+
const install = await deps.exec(deps.npm, updateArgs(dir), { cwd: dir, timeoutMs: 5 * 60 * 1000 });
|
|
227
|
+
if (!install.ok || !fs.existsSync(entry)) {
|
|
228
|
+
return { ok: false, outcome: 'failed', message: `Installing the runner failed: ${install.tail || 'npm did not produce the package'}. ${KEEP_OPEN_HINT}` };
|
|
229
|
+
}
|
|
230
|
+
// 2. The unit that starts it
|
|
231
|
+
const file = unitPath(kind, deps.home);
|
|
232
|
+
const unitOptions = { node: deps.node, entry, home: deps.home, logDir, pathEnv: deps.pathEnv };
|
|
233
|
+
try {
|
|
234
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
235
|
+
fs.writeFileSync(file, kind === 'launchd' ? launchdPlist(unitOptions) : systemdUnit(unitOptions), 'utf8');
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
return { ok: false, outcome: 'failed', message: `Could not write ${file}: ${err instanceof Error ? err.message : String(err)}` };
|
|
239
|
+
}
|
|
240
|
+
// 3. Start it now and at every login
|
|
241
|
+
if (kind === 'launchd') {
|
|
242
|
+
if (deps.uid === null)
|
|
243
|
+
return { ok: false, outcome: 'failed', message: 'Could not determine the current user to start the service for.' };
|
|
244
|
+
const domain = `gui/${deps.uid}`;
|
|
245
|
+
await deps.exec('launchctl', ['bootout', `${domain}/${SERVICE_LABEL}`], { timeoutMs: 20000 }); // an older copy; fine if absent
|
|
246
|
+
let start = await deps.exec('launchctl', ['bootstrap', domain, file], { timeoutMs: 20000 });
|
|
247
|
+
if (!start.ok)
|
|
248
|
+
start = await deps.exec('launchctl', ['load', '-w', file], { timeoutMs: 20000 });
|
|
249
|
+
if (!start.ok) {
|
|
250
|
+
return { ok: false, outcome: 'failed', message: `The runner was installed but macOS would not start it: ${start.tail}. Log out and in again, or run: launchctl load -w "${file}"` };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
await deps.exec('systemctl', ['--user', 'daemon-reload'], { timeoutMs: 20000 });
|
|
255
|
+
const start = await deps.exec('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT], { timeoutMs: 30000 });
|
|
256
|
+
if (!start.ok) {
|
|
257
|
+
return { ok: false, outcome: 'failed', message: `The runner was installed but systemd would not start it: ${start.tail}` };
|
|
258
|
+
}
|
|
259
|
+
// Keep it running when nobody is logged in at the desktop (best effort: needs no password on most systems)
|
|
260
|
+
const user = os.userInfo().username;
|
|
261
|
+
if (which('loginctl'))
|
|
262
|
+
await deps.exec('loginctl', ['enable-linger', user], { timeoutMs: 15000 });
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
ok: true,
|
|
266
|
+
outcome: 'installed',
|
|
267
|
+
message: 'The background runner is installed and running. It starts with this computer and works cards queued from the board, with no coding tool open.',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
export async function uninstallService(options = {}, deps = defaultServiceDeps()) {
|
|
271
|
+
const status = await serviceStatus(deps);
|
|
272
|
+
if (!status.supported || !status.kind || !status.unitPath) {
|
|
273
|
+
return { ok: false, outcome: 'unsupported', message: 'There is no background runner service on this system.' };
|
|
274
|
+
}
|
|
275
|
+
if (!status.installed)
|
|
276
|
+
return { ok: true, outcome: 'absent', message: 'No background runner service is installed.' };
|
|
277
|
+
if (!status.managed && !options.force) {
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
outcome: 'kept',
|
|
281
|
+
message: `The runner service on this computer was not installed by this command (${status.target || status.unitPath}), so it was left as it is. Add --force to remove it anyway.`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (status.kind === 'launchd') {
|
|
285
|
+
if (deps.uid !== null)
|
|
286
|
+
await deps.exec('launchctl', ['bootout', `gui/${deps.uid}/${SERVICE_LABEL}`], { timeoutMs: 20000 });
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
await deps.exec('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT], { timeoutMs: 30000 });
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
fs.rmSync(status.unitPath, { force: true });
|
|
293
|
+
if (status.managed)
|
|
294
|
+
fs.rmSync(runnerDir(deps.amHome), { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
return { ok: false, outcome: 'failed', message: `Stopped the service but could not remove its files: ${err instanceof Error ? err.message : String(err)}` };
|
|
298
|
+
}
|
|
299
|
+
if (status.kind === 'systemd')
|
|
300
|
+
await deps.exec('systemctl', ['--user', 'daemon-reload'], { timeoutMs: 20000 });
|
|
301
|
+
return { ok: true, outcome: 'removed', message: 'The background runner service was stopped and removed. Cards are no longer worked in the background on this computer.' };
|
|
302
|
+
}
|
|
303
|
+
/** npm arguments that install or update the service's own copy of the package. */
|
|
304
|
+
export function updateArgs(dir, version = 'latest') {
|
|
305
|
+
return ['install', '--prefix', dir, '--no-audit', '--no-fund', '--loglevel=error', `${PACKAGE}@${version}`];
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* When this process is the service's own copy, the folder to update in place. Null for a copy started by
|
|
309
|
+
* npx, a global install or a source checkout: those update the way they were installed.
|
|
310
|
+
*/
|
|
311
|
+
export function managedRunnerDir(runningFile, amHome = agileMonsterHome()) {
|
|
312
|
+
const dir = runnerDir(amHome);
|
|
313
|
+
const resolved = path.resolve(runningFile);
|
|
314
|
+
return resolved.startsWith(path.resolve(dir) + path.sep) ? dir : null;
|
|
315
|
+
}
|
|
316
|
+
export async function describeService(deps = defaultServiceDeps()) {
|
|
317
|
+
const s = await serviceStatus(deps);
|
|
318
|
+
if (!s.supported)
|
|
319
|
+
return `Background runner: not available as a service on this system (${deps.platform}). ${KEEP_OPEN_HINT}`;
|
|
320
|
+
if (!s.installed)
|
|
321
|
+
return 'Background runner: not installed. Cards are worked only while your AI coding tool is open in the project folder.\nInstall it: npx -y @agilemonster/mcp-server service install';
|
|
322
|
+
const lines = [`Background runner: installed${s.running ? ' and running' : ', but not running right now'}.`];
|
|
323
|
+
lines.push(s.managed ? ` It starts with this computer and keeps itself up to date (${s.unitPath}).` : ` It was set up by hand and runs ${s.target || 'an unknown program'} (${s.unitPath}).`);
|
|
324
|
+
if (!s.running)
|
|
325
|
+
lines.push(' Start it again: npx -y @agilemonster/mcp-server service install' + (s.managed ? '' : ' --force'));
|
|
326
|
+
return lines.join('\n');
|
|
327
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilemonster/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.26",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Official Model Context Protocol (MCP) server & Orchestrator for Agile Monster (Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf)",
|
|
6
6
|
"main": "dist/server.js",
|
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
],
|
|
48
48
|
"author": "Agile Monster",
|
|
49
49
|
"license": "MIT",
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18.14"
|
|
52
|
+
},
|
|
50
53
|
"dependencies": {
|
|
51
54
|
"@modelcontextprotocol/sdk": "^1.6.1"
|
|
52
55
|
},
|