@jamestesting888/tum-mcp 0.5.0 → 0.6.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/CHANGELOG.md +42 -0
- package/NOTICE +2 -4
- package/README.md +162 -59
- package/docs/development.md +113 -92
- package/docs/setup.md +83 -31
- package/docs/study.md +325 -0
- package/docs/tum-mcp.md +136 -163
- package/npm/backend/manifest.json +3 -3
- package/npm/backend/requirements.txt +22 -18
- package/npm/backend/tum_mcp-0.6.1-py3-none-any.whl +0 -0
- package/npm/lock.mjs +43 -0
- package/npm/npm-command.mjs +96 -0
- package/npm/package-manager.mjs +109 -0
- package/npm/runtime.mjs +177 -61
- package/npm/tum-mcp.mjs +49 -12
- package/package.json +53 -10
- package/npm/backend/tum_moodle_integration-0.5.0-py3-none-any.whl +0 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { delimiter, dirname, join, basename, resolve, relative, isAbsolute, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const packageName = '@jamestesting888/tum-mcp';
|
|
7
|
+
|
|
8
|
+
function canonical(value) {
|
|
9
|
+
let path = resolve(value.replace(/^~(?=$|[/\\])/, homedir()));
|
|
10
|
+
const tail = [];
|
|
11
|
+
while (!existsSync(path)) { tail.unshift(basename(path)); path = dirname(path); }
|
|
12
|
+
return join(realpathSync(path), ...tail);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function packageStorage(root, dataDirectory) {
|
|
16
|
+
const home = homedir();
|
|
17
|
+
const base = process.platform === 'darwin' ? join(home, 'Library/Application Support')
|
|
18
|
+
: process.platform === 'win32' ? process.env.LOCALAPPDATA || join(home, 'AppData/Local')
|
|
19
|
+
: process.env.XDG_DATA_HOME || join(home, '.local/share');
|
|
20
|
+
const storage = {
|
|
21
|
+
TUM_MCP_DATA_DIR: canonical(dataDirectory),
|
|
22
|
+
TUM_MCP_CONFIG_DIR: canonical(process.env.TUM_MCP_CONFIG_DIR || join(dataDirectory, 'config')),
|
|
23
|
+
TUM_MOODLE_DATA_DIR: canonical(process.env.TUM_MOODLE_DATA_DIR || join(base, 'TUMMoodle')),
|
|
24
|
+
TUM_MOODLE_DOWNLOAD_DIR: canonical(process.env.TUM_MOODLE_DOWNLOAD_DIR || join(home, 'Downloads/TUM Moodle')),
|
|
25
|
+
};
|
|
26
|
+
const config = storage.TUM_MCP_CONFIG_DIR;
|
|
27
|
+
const settings = join(config, 'preferences.json');
|
|
28
|
+
let workspace = join(home, 'University');
|
|
29
|
+
if (existsSync(settings)) {
|
|
30
|
+
try {
|
|
31
|
+
const prefs = JSON.parse(readFileSync(settings, 'utf8'));
|
|
32
|
+
if (prefs.workspace_root !== undefined) {
|
|
33
|
+
if (typeof prefs.workspace_root !== 'string') throw new Error('Invalid storage path');
|
|
34
|
+
workspace = prefs.workspace_root;
|
|
35
|
+
}
|
|
36
|
+
} catch { throw new Error('Storage settings could not be verified; package files and saved data were preserved.'); }
|
|
37
|
+
}
|
|
38
|
+
for (const path of [...Object.values(storage), workspace]) {
|
|
39
|
+
const inside = relative(root, canonical(path));
|
|
40
|
+
if (inside === '' || (inside.split(sep)[0] !== '..' && !isAbsolute(inside))) {
|
|
41
|
+
throw new Error('Package installation overlaps saved data or course work. Move that data outside the package before update/removal.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return storage;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function npmCli() {
|
|
48
|
+
const paths = (process.env.PATH || '').split(delimiter);
|
|
49
|
+
const candidates = [process.env.npm_execpath,
|
|
50
|
+
join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'),
|
|
51
|
+
join(dirname(dirname(process.execPath)), 'lib/node_modules/npm/bin/npm-cli.js'),
|
|
52
|
+
...paths.flatMap(path => [join(path, 'npm'), join(path, 'node_modules/npm/bin/npm-cli.js')])];
|
|
53
|
+
for (const path of candidates) {
|
|
54
|
+
if (!path || !existsSync(path)) continue;
|
|
55
|
+
const resolved = realpathSync(path);
|
|
56
|
+
if (basename(resolved) !== 'npm-cli.js') continue;
|
|
57
|
+
const manifest = join(dirname(dirname(resolved)), 'package.json');
|
|
58
|
+
if (existsSync(manifest) && JSON.parse(readFileSync(manifest, 'utf8')).name === 'npm') return resolved;
|
|
59
|
+
}
|
|
60
|
+
throw new Error('npm could not be located beside Node or on PATH. Install Node.js with npm, then retry.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function runNpm(cli, args, capture = false) {
|
|
64
|
+
const result = spawnSync(process.execPath, [cli, ...args, '--ignore-scripts', '--no-audit', '--no-fund', '--no-update-notifier'], {
|
|
65
|
+
shell: false, stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', encoding: 'utf8', timeout: 600000,
|
|
66
|
+
});
|
|
67
|
+
if (result.error || result.status !== 0) throw new Error('npm operation failed. Saved data and retained client runtimes were preserved; resolve the npm error and retry.');
|
|
68
|
+
return capture ? result.stdout.trim() : '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function installationTarget(root) {
|
|
72
|
+
const modules = dirname(dirname(root));
|
|
73
|
+
if (basename(modules) !== 'node_modules' || modules.split(/[\\/]/).some(part => ['.pnpm', '_npx'].includes(part))) {
|
|
74
|
+
throw new Error('Package management requires an npm installation. Use update --local for a source/pinned build.');
|
|
75
|
+
}
|
|
76
|
+
if (`${basename(dirname(root))}/${basename(root)}` !== packageName) {
|
|
77
|
+
throw new Error('Automatic package management requires the original package name; aliases were preserved.');
|
|
78
|
+
}
|
|
79
|
+
const parent = dirname(modules);
|
|
80
|
+
const prefix = process.platform === 'win32' ? parent : dirname(parent);
|
|
81
|
+
const shim = process.platform === 'win32' ? join(prefix, 'tum-mcp.cmd') : join(prefix, 'bin/tum-mcp');
|
|
82
|
+
if (existsSync(shim)) {
|
|
83
|
+
const owned = process.platform === 'win32'
|
|
84
|
+
? readFileSync(shim, 'utf8').replaceAll('\\', '/').includes('node_modules/@jamestesting888/tum-mcp/npm/tum-mcp.mjs')
|
|
85
|
+
: realpathSync(shim) === join(root, 'npm/tum-mcp.mjs');
|
|
86
|
+
if (owned && (process.platform === 'win32' || basename(parent) === 'lib')) return { prefix, global: true };
|
|
87
|
+
}
|
|
88
|
+
const manifest = join(parent, 'package.json');
|
|
89
|
+
if (existsSync(manifest)) {
|
|
90
|
+
const project = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
91
|
+
if (['dependencies', 'devDependencies', 'optionalDependencies'].some(key => project[key]?.[packageName])) {
|
|
92
|
+
return { prefix: parent, global: false };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw new Error('The npm installation prefix could not be verified; package files were preserved.');
|
|
96
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { copyFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { acquireLock } from './lock.mjs';
|
|
8
|
+
import { installationTarget, npmCli, packageName, packageStorage, runNpm } from './npm-command.mjs';
|
|
9
|
+
|
|
10
|
+
function runLauncher(root, args, quiet = false) {
|
|
11
|
+
const result = spawnSync(process.execPath, [join(root, 'npm/tum-mcp.mjs'), ...args], {
|
|
12
|
+
stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit', encoding: 'utf8', shell: false, timeout: 600000,
|
|
13
|
+
});
|
|
14
|
+
if (result.error || result.status !== 0) {
|
|
15
|
+
if (quiet) process.stderr.write((result.stdout || '') + (result.stderr || ''));
|
|
16
|
+
throw new Error(`${args[0]} did not complete. Saved data and customized client files were preserved; review the details and retry.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function versionParts(version) {
|
|
21
|
+
const match = typeof version === 'string' && version.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?(?:\+[\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*)?$/);
|
|
22
|
+
if (!match) throw new Error('npm returned an invalid package version.');
|
|
23
|
+
const prerelease = match[4]?.split('.') || [];
|
|
24
|
+
if (prerelease.some(part => /^0\d+$/.test(part))) throw new Error('npm returned an invalid prerelease version.');
|
|
25
|
+
return { core: match.slice(1, 4).map(BigInt), prerelease };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function newerVersion(latest, current) {
|
|
29
|
+
const left = versionParts(latest), right = versionParts(current);
|
|
30
|
+
for (let i = 0; i < left.core.length; i++) if (left.core[i] !== right.core[i]) return left.core[i] > right.core[i];
|
|
31
|
+
const a = left.prerelease, b = right.prerelease;
|
|
32
|
+
if (!a.length || !b.length) return !a.length && Boolean(b.length);
|
|
33
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
34
|
+
if (a[i] === undefined) return false;
|
|
35
|
+
if (b[i] === undefined) return true;
|
|
36
|
+
if (a[i] === b[i]) continue;
|
|
37
|
+
const numericA = /^\d+$/.test(a[i]), numericB = /^\d+$/.test(b[i]);
|
|
38
|
+
if (numericA && numericB) return BigInt(a[i]) > BigInt(b[i]);
|
|
39
|
+
if (numericA !== numericB) return numericB;
|
|
40
|
+
return a[i] > b[i];
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function operate(job) {
|
|
46
|
+
const release = await acquireLock(job.target.prefix, 'tum-mcp-package');
|
|
47
|
+
try {
|
|
48
|
+
const scope = ['--prefix', job.target.prefix, `--global=${job.target.global}`];
|
|
49
|
+
if (job.action === 'uninstall') {
|
|
50
|
+
const configured = existsSync(join(job.dataDirectory, 'runtime.json')) ||
|
|
51
|
+
existsSync(join(job.configDirectory, 'clients'));
|
|
52
|
+
if (configured || job.args.some(arg => ['--purge-runtime', '--forget-credentials'].includes(arg))) {
|
|
53
|
+
runLauncher(job.root, ['uninstall', '--keep-package', ...job.args]);
|
|
54
|
+
}
|
|
55
|
+
runNpm(job.npm, ['uninstall', ...scope, packageName]);
|
|
56
|
+
console.log('TUM MCP package and managed client setup removed. Saved user data was retained unless explicitly requested.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const current = JSON.parse(readFileSync(join(job.root, 'package.json'), 'utf8')).version;
|
|
60
|
+
const latest = JSON.parse(runNpm(job.npm, ['view', `${packageName}@latest`, 'version', '--json', ...scope], true));
|
|
61
|
+
if (!newerVersion(latest, current)) {
|
|
62
|
+
console.log(`Installed ${current}; no newer npm release. Refreshing configured clients.`);
|
|
63
|
+
runLauncher(job.root, ['update', '--local']);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
console.log(`Preparing TUM MCP ${latest}.`);
|
|
67
|
+
const packed = JSON.parse(runNpm(job.npm, ['pack', `${packageName}@${latest}`, '--pack-destination', job.temporary, '--json', ...scope], true));
|
|
68
|
+
if (!Array.isArray(packed) || packed.length !== 1 || packed[0].name !== packageName || packed[0].version !== latest || basename(packed[0].filename) !== packed[0].filename) {
|
|
69
|
+
throw new Error('npm package metadata did not match the selected release.');
|
|
70
|
+
}
|
|
71
|
+
const archive = join(job.temporary, packed[0].filename);
|
|
72
|
+
const staging = join(job.temporary, 'prepared');
|
|
73
|
+
runNpm(job.npm, ['install', '--prefix', staging, '--global=false', archive], true);
|
|
74
|
+
const prepared = join(staging, 'node_modules', packageName);
|
|
75
|
+
runLauncher(prepared, ['setup'], true);
|
|
76
|
+
// Only a healthy prepared release may replace the user's npm installation.
|
|
77
|
+
runNpm(job.npm, ['install', ...scope, job.target.global ? archive : `${packageName}@${latest}`]);
|
|
78
|
+
if (JSON.parse(readFileSync(join(job.root, 'package.json'), 'utf8')).version !== latest) throw new Error('npm did not install the selected version; client activation was stopped.');
|
|
79
|
+
runLauncher(job.root, ['update', '--local']);
|
|
80
|
+
console.log(`Updated TUM MCP to ${latest}. Restart clients to load refreshed skills.`);
|
|
81
|
+
} finally { await release(); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function managePackage(action, args, dataDirectory) {
|
|
85
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
86
|
+
const inside = relative(root, process.cwd());
|
|
87
|
+
if (inside === '' || (inside.split(sep)[0] !== '..' && !isAbsolute(inside))) throw new Error('Run package update/removal from outside the installed package directory.');
|
|
88
|
+
const target = installationTarget(root);
|
|
89
|
+
const storage = packageStorage(root, dataDirectory);
|
|
90
|
+
const configDirectory = storage.TUM_MCP_CONFIG_DIR;
|
|
91
|
+
const npm = npmCli();
|
|
92
|
+
const temporary = await mkdtemp(join(tmpdir(), 'tum-mcp-package-'));
|
|
93
|
+
try {
|
|
94
|
+
for (const name of ['package-manager.mjs', 'npm-command.mjs', 'lock.mjs']) await copyFile(join(root, 'npm', name), join(temporary, name));
|
|
95
|
+
const job = { action, args, root, dataDirectory, configDirectory, target, npm, temporary };
|
|
96
|
+
const path = join(temporary, 'job.json');
|
|
97
|
+
await writeFile(path, JSON.stringify(job), { mode: 0o600, flag: 'wx' });
|
|
98
|
+
// This worker and its cwd survive Windows replacing/removing the package files.
|
|
99
|
+
const result = spawnSync(process.execPath, [join(temporary, 'package-manager.mjs'), '--job', path], {
|
|
100
|
+
cwd: temporary, stdio: 'inherit', shell: false, env: { ...process.env, ...storage },
|
|
101
|
+
});
|
|
102
|
+
return result.status ?? (result.signal === 'SIGINT' ? 130 : 2);
|
|
103
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (process.argv[1] && process.argv[2] === '--job' && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
107
|
+
try { await operate(JSON.parse(readFileSync(process.argv[3], 'utf8'))); }
|
|
108
|
+
catch (error) { console.error(error.message); process.exitCode = 2; }
|
|
109
|
+
}
|
package/npm/runtime.mjs
CHANGED
|
@@ -1,96 +1,212 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
-
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { chmod, mkdir, mkdtemp, rename, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { download } from './download.mjs';
|
|
9
|
+
import { acquireLock } from './lock.mjs';
|
|
9
10
|
|
|
10
11
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
12
|
const pins = JSON.parse(readFileSync(join(packageRoot, 'npm/uv-artifacts.json'), 'utf8'));
|
|
13
|
+
const packageVersion = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
12
14
|
|
|
13
15
|
export function dataRoot() {
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
let path = process.env.TUM_MCP_DATA_DIR;
|
|
17
|
+
if (!path) {
|
|
18
|
+
if (process.platform === 'darwin') path = join(homedir(), 'Library/Application Support/TUMMCP');
|
|
19
|
+
else if (process.platform === 'win32') path = join(process.env.LOCALAPPDATA || join(homedir(), 'AppData/Local'), 'TUMMCP');
|
|
20
|
+
else path = join(process.env.XDG_DATA_HOME || join(homedir(), '.local/share'), 'TUMMCP');
|
|
21
|
+
}
|
|
22
|
+
path = resolve(path.replace(/^~(?=$|[/\\])/, homedir()));
|
|
23
|
+
const tail = [];
|
|
24
|
+
while (!existsSync(path)) { tail.unshift(basename(path)); path = dirname(path); }
|
|
25
|
+
return join(realpathSync(path), ...tail);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function manifest() {
|
|
29
|
+
const value = JSON.parse(readFileSync(join(packageRoot, 'npm/backend/manifest.json'), 'utf8'));
|
|
30
|
+
if (!/^[a-zA-Z0-9_.-]+\.whl$/.test(value.wheel) ||
|
|
31
|
+
!/^[a-f0-9]{64}$/.test(value.wheelSha256) || !/^[a-f0-9]{64}$/.test(value.requirementsSha256)) {
|
|
32
|
+
throw new Error('Invalid backend manifest; reinstall the npm package.');
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
18
35
|
}
|
|
19
36
|
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const result = spawnSync(executable, args, { env, stdio: ['ignore', 'inherit', 'inherit'], shell: false, timeout: 600000 });
|
|
24
|
-
if (result.error) throw new Error(`Managed runtime subprocess could not start (${result.error.code || 'unknown'}); check executable permissions and mount options.`);
|
|
25
|
-
if (result.status !== 0) throw new Error('Managed runtime setup failed; inspect the diagnostics above.');
|
|
37
|
+
export function runtimeIdentity(value = manifest()) {
|
|
38
|
+
return createHash('sha256').update(JSON.stringify({ schema: 2, wheel: value.wheelSha256,
|
|
39
|
+
requirements: value.requirementsSha256, python: pins.python })).digest('hex');
|
|
26
40
|
}
|
|
27
41
|
|
|
42
|
+
function controlDirectory(identity) { return join(dataRoot(), 'runtimes', identity); }
|
|
43
|
+
function pythonPath(runtime) { return join(runtime, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'); }
|
|
44
|
+
|
|
28
45
|
export function currentPython() {
|
|
29
46
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
const runtime = join(
|
|
35
|
-
const expected =
|
|
36
|
-
if (
|
|
37
|
-
if (
|
|
47
|
+
const identity = runtimeIdentity();
|
|
48
|
+
const control = controlDirectory(identity);
|
|
49
|
+
const selected = JSON.parse(readFileSync(join(control, 'current.json'), 'utf8'));
|
|
50
|
+
if (selected.schema !== 2 || selected.identity !== identity || !/^env-[a-zA-Z0-9]+$/.test(selected.generation)) return null;
|
|
51
|
+
const runtime = join(control, selected.generation);
|
|
52
|
+
const expected = pythonPath(runtime);
|
|
53
|
+
if (selected.python !== expected || !existsSync(expected)) return null;
|
|
54
|
+
if ([join(dataRoot(), 'runtimes'), control, runtime, join(runtime, '.tum-mcp-ready')].some(path => lstatSync(path).isSymbolicLink())) return null;
|
|
55
|
+
if (readFileSync(join(runtime, '.tum-mcp-ready'), 'utf8') !== identity) return null;
|
|
38
56
|
return expected;
|
|
39
57
|
} catch { return null; }
|
|
40
58
|
}
|
|
41
59
|
|
|
60
|
+
function run(executable, args, { quiet = false, timeout = 600000 } = {}) {
|
|
61
|
+
const env = { ...process.env }; delete env.VIRTUAL_ENV; delete env.PYTHONPATH; delete env.PYTHONHOME;
|
|
62
|
+
const result = spawnSync(executable, args, { env, stdio: quiet ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'], shell: false, timeout });
|
|
63
|
+
if (result.error) throw new Error(`Managed runtime subprocess could not start (${result.error.code || 'unknown'}); check permissions and retry setup.`);
|
|
64
|
+
if (result.status !== 0) throw new Error('Managed runtime setup failed; the previous runtime was retained.');
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function healthy(python) {
|
|
69
|
+
try {
|
|
70
|
+
const result = run(python, ['-I', '-c', 'from tum_mcp.server import server; from tum_mcp import __version__; print(__version__)'], { quiet: true, timeout: 30000 });
|
|
71
|
+
return result.stdout.toString().trim() === packageVersion;
|
|
72
|
+
} catch { return false; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function atomicWrite(path, data) {
|
|
76
|
+
const temporary = `${path}.${randomUUID()}.pending`;
|
|
77
|
+
try {
|
|
78
|
+
await writeFile(temporary, data, { mode: 0o600, flag: 'wx' });
|
|
79
|
+
await rename(temporary, path);
|
|
80
|
+
} finally { await rm(temporary, { force: true }); }
|
|
81
|
+
}
|
|
82
|
+
|
|
42
83
|
function matchingUv(executable) {
|
|
43
84
|
const found = spawnSync(executable, ['--version'], { encoding: 'utf8', shell: false, timeout: 10000 });
|
|
44
85
|
return found.status === 0 && found.stdout.trim().split(' ')[1] === pins.version;
|
|
45
86
|
}
|
|
46
87
|
|
|
88
|
+
async function uvExecutable() {
|
|
89
|
+
const cached = join(dataRoot(), process.platform === 'win32' ? 'uv.exe' : 'uv');
|
|
90
|
+
if (matchingUv('uv')) return 'uv';
|
|
91
|
+
if (matchingUv(cached)) {
|
|
92
|
+
await atomicWrite(join(dataRoot(), 'uv-owner.json'), JSON.stringify({ sha256: createHash('sha256').update(readFileSync(cached)).digest('hex') }));
|
|
93
|
+
return cached;
|
|
94
|
+
}
|
|
95
|
+
const systems = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' };
|
|
96
|
+
const arches = { arm64: 'aarch64', x64: 'x86_64' };
|
|
97
|
+
const target = `${arches[process.arch]}-${systems[process.platform]}`;
|
|
98
|
+
const artifact = pins.artifacts[target];
|
|
99
|
+
if (!artifact) throw new Error('Unsupported platform/architecture; use a documented manual installation.');
|
|
100
|
+
const temporary = await mkdtemp(join(dataRoot(), 'bootstrap-'));
|
|
101
|
+
try {
|
|
102
|
+
const archive = join(temporary, process.platform === 'win32' ? 'uv.zip' : 'uv.tar.gz');
|
|
103
|
+
await download(artifact.url, archive, artifact.sha256);
|
|
104
|
+
run('tar', ['-xf', archive, '-C', temporary]);
|
|
105
|
+
const extracted = process.platform === 'win32' ? join(temporary, 'uv.exe') : join(temporary, `uv-${target}`, 'uv');
|
|
106
|
+
await rename(extracted, cached);
|
|
107
|
+
if (process.platform !== 'win32') await chmod(cached, 0o700);
|
|
108
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
109
|
+
await atomicWrite(join(dataRoot(), 'uv-owner.json'), JSON.stringify({ sha256: createHash('sha256').update(readFileSync(cached)).digest('hex') }));
|
|
110
|
+
return cached;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function acquireSetupLock() { return acquireLock(dataRoot()); }
|
|
114
|
+
|
|
47
115
|
export async function ensureRuntime() {
|
|
48
116
|
const backend = join(packageRoot, 'npm/backend');
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
for (const [name, expected] of [[manifest.wheel, manifest.wheelSha256], ['requirements.txt', manifest.requirementsSha256]]) {
|
|
117
|
+
const value = manifest();
|
|
118
|
+
for (const [name, expected] of [[value.wheel, value.wheelSha256], ['requirements.txt', value.requirementsSha256]]) {
|
|
52
119
|
if (createHash('sha256').update(readFileSync(join(backend, name))).digest('hex') !== expected) throw new Error('Backend integrity check failed.');
|
|
53
120
|
}
|
|
54
|
-
const identity =
|
|
55
|
-
const runtime = join(dataRoot(), 'runtimes', identity);
|
|
56
|
-
const python = join(runtime, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
|
|
121
|
+
const identity = runtimeIdentity(value);
|
|
57
122
|
await mkdir(dataRoot(), { recursive: true, mode: 0o700 });
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
try { await mkdir(lock, { mode: 0o700 }); }
|
|
61
|
-
catch (error) { if (error.code === 'EEXIST') throw new Error('Another setup is running; retry when it finishes.'); throw error; }
|
|
123
|
+
const release = await acquireSetupLock();
|
|
124
|
+
let preparing;
|
|
62
125
|
try {
|
|
63
|
-
|
|
64
|
-
if (!
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const arches = { arm64: 'aarch64', x64: 'x86_64' };
|
|
70
|
-
const target = `${arches[process.arch]}-${systems[process.platform]}`;
|
|
71
|
-
const artifact = pins.artifacts[target];
|
|
72
|
-
if (!artifact) throw new Error('Unsupported platform/architecture; use a documented manual installation.');
|
|
73
|
-
const temporary = await mkdtemp(join(dataRoot(), 'bootstrap-'));
|
|
74
|
-
try {
|
|
75
|
-
const archive = join(temporary, process.platform === 'win32' ? 'uv.zip' : 'uv.tar.gz');
|
|
76
|
-
await download(artifact.url, archive, artifact.sha256);
|
|
77
|
-
run('tar', ['-xf', archive, '-C', temporary]);
|
|
78
|
-
const extracted = process.platform === 'win32' ? join(temporary, 'uv.exe') : join(temporary, `uv-${target}`, 'uv');
|
|
79
|
-
uv = join(dataRoot(), process.platform === 'win32' ? 'uv.exe' : 'uv');
|
|
80
|
-
await rename(extracted, uv); if (process.platform !== 'win32') await chmod(uv, 0o700);
|
|
81
|
-
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
126
|
+
let python = currentPython();
|
|
127
|
+
if (!python || !healthy(python)) {
|
|
128
|
+
const uv = await uvExecutable();
|
|
129
|
+
const control = controlDirectory(identity);
|
|
130
|
+
for (const path of [join(dataRoot(), 'runtimes'), control]) {
|
|
131
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error('Symbolic runtime directory preserved; review it locally.');
|
|
82
132
|
}
|
|
83
|
-
await mkdir(
|
|
84
|
-
|
|
85
|
-
|
|
133
|
+
await mkdir(control, { recursive: true, mode: 0o700 });
|
|
134
|
+
await atomicWrite(join(control, 'runtime-owner.json'), JSON.stringify({ kind: 'tum-mcp-runtime', schema: 2, identity }));
|
|
135
|
+
preparing = await mkdtemp(join(control, 'env-'));
|
|
136
|
+
python = pythonPath(preparing);
|
|
137
|
+
run(uv, ['venv', '--managed-python', '--python', pins.python, preparing]);
|
|
86
138
|
run(uv, ['pip', 'sync', '--python', python, '--require-hashes', '--only-binary', ':all:', join(backend, 'requirements.txt')]);
|
|
87
|
-
run(uv, ['pip', 'install', '--python', python, '--no-deps', '--no-index', join(backend,
|
|
139
|
+
run(uv, ['pip', 'install', '--python', python, '--no-deps', '--no-index', join(backend, value.wheel)]);
|
|
140
|
+
if (!healthy(python)) throw new Error('New runtime validation failed; the previous runtime was retained.');
|
|
141
|
+
await atomicWrite(join(preparing, '.tum-mcp-ready'), identity);
|
|
142
|
+
const selected = JSON.stringify({ schema: 2, identity, generation: basename(preparing), python });
|
|
143
|
+
await atomicWrite(join(control, 'current.json'), selected);
|
|
144
|
+
// Published generations are never removed: existing clients may still pin one.
|
|
145
|
+
preparing = undefined;
|
|
146
|
+
await atomicWrite(join(dataRoot(), 'runtime.json'), selected);
|
|
88
147
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
await
|
|
93
|
-
await rename(join(dataRoot(), 'runtime.pending.json'), join(dataRoot(), 'runtime.json'));
|
|
148
|
+
const selected = readFileSync(join(controlDirectory(identity), 'current.json'), 'utf8');
|
|
149
|
+
const global = join(dataRoot(), 'runtime.json');
|
|
150
|
+
if (!existsSync(global) || readFileSync(global, 'utf8') !== selected) await atomicWrite(global, selected);
|
|
151
|
+
await rm(join(dataRoot(), 'runtime-removal.pending'), { force: true });
|
|
94
152
|
return python;
|
|
95
|
-
} finally {
|
|
153
|
+
} finally {
|
|
154
|
+
try { if (preparing) await rm(preparing, { recursive: true, force: true }); }
|
|
155
|
+
finally { await release(); }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
export async function removeRuntimeFiles() {
|
|
161
|
+
const root = dataRoot();
|
|
162
|
+
const directory = join(root, 'runtimes');
|
|
163
|
+
if (!existsSync(join(root, 'runtime-removal.pending'))) throw new Error('Runtime removal must be requested with uninstall --all --purge-runtime.');
|
|
164
|
+
const owned = [];
|
|
165
|
+
if (existsSync(directory)) {
|
|
166
|
+
if (lstatSync(directory).isSymbolicLink()) throw new Error('Symbolic runtime directory preserved for review.');
|
|
167
|
+
for (const name of readdirSync(directory)) {
|
|
168
|
+
const runtime = join(directory, name);
|
|
169
|
+
if (!/^[a-f0-9]{64}$/.test(name) || lstatSync(runtime).isSymbolicLink()) throw new Error('Unrecognized runtime files were preserved for review.');
|
|
170
|
+
let verified = false;
|
|
171
|
+
const owner = join(runtime, 'runtime-owner.json');
|
|
172
|
+
if (existsSync(owner) && !lstatSync(owner).isSymbolicLink()) {
|
|
173
|
+
const record = JSON.parse(readFileSync(owner, 'utf8'));
|
|
174
|
+
verified = record.kind === 'tum-mcp-runtime' && record.schema === 2 && record.identity === name;
|
|
175
|
+
}
|
|
176
|
+
const legacy = join(runtime, '.tum-mcp-ready');
|
|
177
|
+
if (!verified && existsSync(legacy) && !lstatSync(legacy).isSymbolicLink()) verified = readFileSync(legacy, 'utf8') === name;
|
|
178
|
+
if (!verified) throw new Error('Runtime ownership could not be verified; files were preserved.');
|
|
179
|
+
owned.push(runtime);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const pointer = join(root, 'runtime.json');
|
|
183
|
+
let removePointer = false;
|
|
184
|
+
if (existsSync(pointer)) {
|
|
185
|
+
let current;
|
|
186
|
+
try { current = JSON.parse(readFileSync(pointer, 'utf8')); }
|
|
187
|
+
catch { throw new Error('Runtime selection metadata is unreadable; runtimes were preserved. Run setup to repair it.'); }
|
|
188
|
+
removePointer = /^[a-f0-9]{64}$/.test(current.identity) && owned.includes(join(directory, current.identity));
|
|
189
|
+
}
|
|
190
|
+
// All candidates are checked before deletion; never remove the campus data root.
|
|
191
|
+
for (const runtime of owned) await rm(runtime, { recursive: true });
|
|
192
|
+
if (existsSync(directory) && readdirSync(directory).length === 0) await rmdir(directory);
|
|
193
|
+
if (removePointer) await rm(pointer);
|
|
194
|
+
const uv = join(root, process.platform === 'win32' ? 'uv.exe' : 'uv');
|
|
195
|
+
const uvOwner = join(root, 'uv-owner.json');
|
|
196
|
+
if (existsSync(uvOwner) && existsSync(uv)) {
|
|
197
|
+
const expected = JSON.parse(readFileSync(uvOwner, 'utf8')).sha256;
|
|
198
|
+
if (!lstatSync(uv).isSymbolicLink() && createHash('sha256').update(readFileSync(uv)).digest('hex') === expected) {
|
|
199
|
+
await rm(uv); await rm(uvOwner);
|
|
200
|
+
} else console.error('Modified bootstrap cache was preserved for local review.');
|
|
201
|
+
}
|
|
202
|
+
const leases = join(root, 'runtime-leases');
|
|
203
|
+
if (existsSync(leases) && !lstatSync(leases).isSymbolicLink()) {
|
|
204
|
+
for (const name of readdirSync(leases)) {
|
|
205
|
+
const lock = join(leases, name);
|
|
206
|
+
if (/^[a-f0-9]{32}\.lock$/.test(name) && !lstatSync(lock).isSymbolicLink()) await rm(lock);
|
|
207
|
+
}
|
|
208
|
+
if (readdirSync(leases).length === 0) await rmdir(leases);
|
|
209
|
+
}
|
|
210
|
+
await rm(join(root, 'runtime-removal.pending'), { force: true });
|
|
211
|
+
console.log(`Removed ${owned.length} managed runtime(s). Saved user data was retained.`);
|
|
96
212
|
}
|
package/npm/tum-mcp.mjs
CHANGED
|
@@ -1,21 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { acquireSetupLock, currentPython, dataRoot, ensureRuntime, removeRuntimeFiles } from './runtime.mjs';
|
|
6
|
+
import { managePackage } from './package-manager.mjs';
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
let args = process.argv.slice(2);
|
|
9
|
+
const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
6
10
|
try {
|
|
7
11
|
if (args.length === 0 || (args.length === 1 && ['--help', '-h'].includes(args[0]))) {
|
|
8
|
-
console.log(
|
|
12
|
+
console.log(`TUM MCP ${version}\nCommands: setup [--client codex|claude|cursor|antigravity|generic], connect, scope, discover, status, doctor [--json] [--live], serve, skills, study, logout, uninstall, update\nRun connect without arguments to select services and an installed Chromium browser. Run setup explicitly to provision the managed Python backend.`);
|
|
9
13
|
} else if (['--version', 'version'].includes(args[0])) {
|
|
10
|
-
console.log(
|
|
14
|
+
console.log(version);
|
|
15
|
+
} else if (['setup', 'update', 'uninstall'].includes(args[0]) && args.some(arg => ['--help', '-h'].includes(arg))) {
|
|
16
|
+
const help = {
|
|
17
|
+
update: 'Usage: tum-mcp update [--local]\nInstall the latest npm release and refresh configured clients.\n--local refreshes this installed version without contacting npm.',
|
|
18
|
+
setup: 'Usage: tum-mcp setup [--client codex|claude|cursor|antigravity|generic] [--host local|windows]\nProvision the managed runtime and register explicitly selected clients.',
|
|
19
|
+
uninstall: 'Usage: tum-mcp uninstall [--all] [--purge-runtime] [--forget-credentials]\nRemove managed client setup and this npm installation. Saved data is retained by default.\n--client NAME removes one client only. --keep-package keeps npm installed.',
|
|
20
|
+
};
|
|
21
|
+
console.log(help[args[0]]);
|
|
11
22
|
} else {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
let packageAction = args[0] === 'update';
|
|
24
|
+
if (args[0] === 'update') {
|
|
25
|
+
if (args.length > 2 || (args.length === 2 && args[1] !== '--local')) throw new Error('Usage: tum-mcp update [--local]. Use --help for details.');
|
|
26
|
+
if (args[1] === '--local') { packageAction = false; args = ['update']; }
|
|
27
|
+
}
|
|
28
|
+
if (args[0] === 'uninstall') {
|
|
29
|
+
const { values } = parseArgs({ args: args.slice(1), options: {
|
|
30
|
+
all: { type: 'boolean' }, client: { type: 'string' }, host: { type: 'string' },
|
|
31
|
+
'keep-package': { type: 'boolean' }, 'purge-runtime': { type: 'boolean' }, 'forget-credentials': { type: 'boolean' },
|
|
32
|
+
} });
|
|
33
|
+
if (values.host && !['local', 'windows'].includes(values.host)) throw new Error('Choose --host local or windows.');
|
|
34
|
+
if (values.client !== undefined && !['codex', 'claude', 'cursor', 'antigravity', 'generic'].includes(values.client)) throw new Error('Choose a supported --client name.');
|
|
35
|
+
if (values.client && (values.all || values['purge-runtime'] || values['forget-credentials'])) throw new Error('Client-only removal cannot use --all or data/runtime cleanup options.');
|
|
36
|
+
if (!values.client && !values.all) args.push('--all');
|
|
37
|
+
packageAction = !values.client && !values['keep-package'];
|
|
38
|
+
args = args.filter(arg => arg !== '--keep-package');
|
|
39
|
+
}
|
|
40
|
+
if (packageAction) {
|
|
41
|
+
process.exitCode = await managePackage(args[0], args.slice(1), dataRoot());
|
|
42
|
+
} else {
|
|
43
|
+
let python = ['setup', 'update'].includes(args[0]) ? await ensureRuntime() : currentPython();
|
|
44
|
+
if (args[0] === 'uninstall' && !python) python = await ensureRuntime();
|
|
45
|
+
if (!python) throw new Error('SETUP_REQUIRED: Run tum-mcp setup first.');
|
|
46
|
+
const forwarded = args;
|
|
47
|
+
const env = { ...process.env, TUM_MCP_NPM_LAUNCHER: '1' }; delete env.PYTHONPATH; delete env.PYTHONHOME;
|
|
48
|
+
const purge = args[0] === 'uninstall' && args.includes('--purge-runtime');
|
|
49
|
+
const release = purge ? await acquireSetupLock() : null;
|
|
50
|
+
try {
|
|
51
|
+
const child = spawnSync(python, ['-I', '-m', 'tum_mcp', ...forwarded], { env, stdio: 'inherit', shell: false });
|
|
52
|
+
if (child.error) throw new Error('Managed backend could not start; run setup again.');
|
|
53
|
+
process.exitCode = child.status ?? (child.signal === 'SIGINT' ? 130 : 1);
|
|
54
|
+
if (purge && child.status === 0) await removeRuntimeFiles();
|
|
55
|
+
} finally { if (release) await release(); }
|
|
56
|
+
}
|
|
20
57
|
}
|
|
21
58
|
} catch (error) { console.error(error.message); process.exitCode = 2; }
|
package/package.json
CHANGED
|
@@ -1,16 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jamestesting888/tum-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "Read-only TUM Moodle, Artemis and Zulip integration with source-backed study workflows",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
"bin": {
|
|
8
|
+
"tum-mcp": "npm/tum-mcp.mjs"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22.14.0"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"npm/tum-mcp.mjs",
|
|
15
|
+
"npm/runtime.mjs",
|
|
16
|
+
"npm/download.mjs",
|
|
17
|
+
"npm/lock.mjs",
|
|
18
|
+
"npm/npm-command.mjs",
|
|
19
|
+
"npm/package-manager.mjs",
|
|
20
|
+
"npm/uv-artifacts.json",
|
|
21
|
+
"npm/backend/*.whl",
|
|
22
|
+
"npm/backend/requirements.txt",
|
|
23
|
+
"npm/backend/manifest.json",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"NOTICE",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"README.md",
|
|
28
|
+
"docs/setup.md",
|
|
29
|
+
"docs/development.md",
|
|
30
|
+
"docs/tum-mcp.md",
|
|
31
|
+
"docs/study.md"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"prepack": "node npm/build.mjs",
|
|
35
|
+
"test": "node --test npm/test/*.test.mjs",
|
|
36
|
+
"test:consumer": "node npm/smoke-consumer.mjs",
|
|
37
|
+
"test:upgrade": "node npm/upgrade-consumer.mjs",
|
|
38
|
+
"test:package-lifecycle": "node npm/package-consumer.mjs"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/jamestesting0/tum-mcp.git"
|
|
46
|
+
},
|
|
13
47
|
"homepage": "https://github.com/jamestesting0/tum-mcp#readme",
|
|
14
|
-
"bugs": {
|
|
15
|
-
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/jamestesting0/tum-mcp/issues"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"mcp",
|
|
53
|
+
"tum",
|
|
54
|
+
"moodle",
|
|
55
|
+
"artemis",
|
|
56
|
+
"zulip",
|
|
57
|
+
"codex"
|
|
58
|
+
]
|
|
16
59
|
}
|
|
Binary file
|