@jamestesting888/tum-mcp 0.4.1 → 0.6.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/CHANGELOG.md +29 -0
- package/NOTICE +2 -4
- package/README.md +157 -59
- package/docs/development.md +118 -80
- package/docs/setup.md +164 -147
- package/docs/study.md +325 -0
- package/docs/tum-mcp.md +184 -135
- package/npm/backend/manifest.json +3 -3
- package/npm/backend/requirements.txt +62 -17
- package/npm/backend/tum_mcp-0.6.0-py3-none-any.whl +0 -0
- package/npm/runtime.mjs +210 -62
- package/npm/tum-mcp.mjs +20 -8
- package/package.json +50 -11
- package/docs/credential-storage-options.md +0 -86
- package/npm/backend/tum_moodle_integration-0.4.1-py3-none-any.whl +0 -0
package/npm/runtime.mjs
CHANGED
|
@@ -1,96 +1,244 @@
|
|
|
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';
|
|
5
|
-
import { homedir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
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
|
+
import { homedir, hostname } from 'node:os';
|
|
6
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { download } from './download.mjs';
|
|
9
9
|
|
|
10
10
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
11
|
const pins = JSON.parse(readFileSync(join(packageRoot, 'npm/uv-artifacts.json'), 'utf8'));
|
|
12
|
+
const packageVersion = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
12
13
|
|
|
13
14
|
export function dataRoot() {
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
let path = process.env.TUM_MCP_DATA_DIR;
|
|
16
|
+
if (!path) {
|
|
17
|
+
if (process.platform === 'darwin') path = join(homedir(), 'Library/Application Support/TUMMCP');
|
|
18
|
+
else if (process.platform === 'win32') path = join(process.env.LOCALAPPDATA || join(homedir(), 'AppData/Local'), 'TUMMCP');
|
|
19
|
+
else path = join(process.env.XDG_DATA_HOME || join(homedir(), '.local/share'), 'TUMMCP');
|
|
20
|
+
}
|
|
21
|
+
path = resolve(path.replace(/^~(?=$|[/\\])/, homedir()));
|
|
22
|
+
const tail = [];
|
|
23
|
+
while (!existsSync(path)) { tail.unshift(basename(path)); path = dirname(path); }
|
|
24
|
+
return join(realpathSync(path), ...tail);
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
function
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
function manifest() {
|
|
28
|
+
const value = JSON.parse(readFileSync(join(packageRoot, 'npm/backend/manifest.json'), 'utf8'));
|
|
29
|
+
if (!/^[a-zA-Z0-9_.-]+\.whl$/.test(value.wheel) ||
|
|
30
|
+
!/^[a-f0-9]{64}$/.test(value.wheelSha256) || !/^[a-f0-9]{64}$/.test(value.requirementsSha256)) {
|
|
31
|
+
throw new Error('Invalid backend manifest; reinstall the npm package.');
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
export function runtimeIdentity(value = manifest()) {
|
|
37
|
+
return createHash('sha256').update(JSON.stringify({ schema: 2, wheel: value.wheelSha256,
|
|
38
|
+
requirements: value.requirementsSha256, python: pins.python })).digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function controlDirectory(identity) { return join(dataRoot(), 'runtimes', identity); }
|
|
42
|
+
function pythonPath(runtime) { return join(runtime, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'); }
|
|
43
|
+
|
|
28
44
|
export function currentPython() {
|
|
29
45
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
const runtime = join(
|
|
35
|
-
const expected =
|
|
36
|
-
if (
|
|
37
|
-
if (
|
|
46
|
+
const identity = runtimeIdentity();
|
|
47
|
+
const control = controlDirectory(identity);
|
|
48
|
+
const selected = JSON.parse(readFileSync(join(control, 'current.json'), 'utf8'));
|
|
49
|
+
if (selected.schema !== 2 || selected.identity !== identity || !/^env-[a-zA-Z0-9]+$/.test(selected.generation)) return null;
|
|
50
|
+
const runtime = join(control, selected.generation);
|
|
51
|
+
const expected = pythonPath(runtime);
|
|
52
|
+
if (selected.python !== expected || !existsSync(expected)) return null;
|
|
53
|
+
if ([join(dataRoot(), 'runtimes'), control, runtime, join(runtime, '.tum-mcp-ready')].some(path => lstatSync(path).isSymbolicLink())) return null;
|
|
54
|
+
if (readFileSync(join(runtime, '.tum-mcp-ready'), 'utf8') !== identity) return null;
|
|
38
55
|
return expected;
|
|
39
56
|
} catch { return null; }
|
|
40
57
|
}
|
|
41
58
|
|
|
59
|
+
function run(executable, args, { quiet = false, timeout = 600000 } = {}) {
|
|
60
|
+
const env = { ...process.env }; delete env.VIRTUAL_ENV; delete env.PYTHONPATH; delete env.PYTHONHOME;
|
|
61
|
+
const result = spawnSync(executable, args, { env, stdio: quiet ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'], shell: false, timeout });
|
|
62
|
+
if (result.error) throw new Error(`Managed runtime subprocess could not start (${result.error.code || 'unknown'}); check permissions and retry setup.`);
|
|
63
|
+
if (result.status !== 0) throw new Error('Managed runtime setup failed; the previous runtime was retained.');
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function healthy(python) {
|
|
68
|
+
try {
|
|
69
|
+
const result = run(python, ['-I', '-c', 'from tum_mcp.server import server; from tum_mcp import __version__; print(__version__)'], { quiet: true, timeout: 30000 });
|
|
70
|
+
return result.stdout.toString().trim() === packageVersion;
|
|
71
|
+
} catch { return false; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function atomicWrite(path, data) {
|
|
75
|
+
const temporary = `${path}.${randomUUID()}.pending`;
|
|
76
|
+
try {
|
|
77
|
+
await writeFile(temporary, data, { mode: 0o600, flag: 'wx' });
|
|
78
|
+
await rename(temporary, path);
|
|
79
|
+
} finally { await rm(temporary, { force: true }); }
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
function matchingUv(executable) {
|
|
43
83
|
const found = spawnSync(executable, ['--version'], { encoding: 'utf8', shell: false, timeout: 10000 });
|
|
44
84
|
return found.status === 0 && found.stdout.trim().split(' ')[1] === pins.version;
|
|
45
85
|
}
|
|
46
86
|
|
|
87
|
+
async function uvExecutable() {
|
|
88
|
+
const cached = join(dataRoot(), process.platform === 'win32' ? 'uv.exe' : 'uv');
|
|
89
|
+
if (matchingUv('uv')) return 'uv';
|
|
90
|
+
if (matchingUv(cached)) {
|
|
91
|
+
await atomicWrite(join(dataRoot(), 'uv-owner.json'), JSON.stringify({ sha256: createHash('sha256').update(readFileSync(cached)).digest('hex') }));
|
|
92
|
+
return cached;
|
|
93
|
+
}
|
|
94
|
+
const systems = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' };
|
|
95
|
+
const arches = { arm64: 'aarch64', x64: 'x86_64' };
|
|
96
|
+
const target = `${arches[process.arch]}-${systems[process.platform]}`;
|
|
97
|
+
const artifact = pins.artifacts[target];
|
|
98
|
+
if (!artifact) throw new Error('Unsupported platform/architecture; use a documented manual installation.');
|
|
99
|
+
const temporary = await mkdtemp(join(dataRoot(), 'bootstrap-'));
|
|
100
|
+
try {
|
|
101
|
+
const archive = join(temporary, process.platform === 'win32' ? 'uv.zip' : 'uv.tar.gz');
|
|
102
|
+
await download(artifact.url, archive, artifact.sha256);
|
|
103
|
+
run('tar', ['-xf', archive, '-C', temporary]);
|
|
104
|
+
const extracted = process.platform === 'win32' ? join(temporary, 'uv.exe') : join(temporary, `uv-${target}`, 'uv');
|
|
105
|
+
await rename(extracted, cached);
|
|
106
|
+
if (process.platform !== 'win32') await chmod(cached, 0o700);
|
|
107
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
108
|
+
await atomicWrite(join(dataRoot(), 'uv-owner.json'), JSON.stringify({ sha256: createHash('sha256').update(readFileSync(cached)).digest('hex') }));
|
|
109
|
+
return cached;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function acquireSetupLock() {
|
|
113
|
+
const lock = join(dataRoot(), 'setup.lock');
|
|
114
|
+
const owner = { pid: process.pid, host: hostname(), nonce: randomUUID() };
|
|
115
|
+
const guard = join(dataRoot(), 'setup.claim');
|
|
116
|
+
try { await mkdir(guard, { mode: 0o700 }); }
|
|
117
|
+
catch { throw new Error('Another setup is claiming the lock. Retry; an interrupted setup.claim needs local review.'); }
|
|
118
|
+
try {
|
|
119
|
+
try { await mkdir(lock, { mode: 0o700 }); }
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code !== 'EEXIST') throw error;
|
|
122
|
+
let previous;
|
|
123
|
+
try { previous = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')); }
|
|
124
|
+
catch { throw new Error('Setup lock has no readable owner. Confirm no setup is running before removing setup.lock.'); }
|
|
125
|
+
if (!Number.isSafeInteger(previous.pid) || previous.pid <= 0 || previous.host !== hostname()) {
|
|
126
|
+
throw new Error('Setup lock belongs to an unknown owner; preserve it for local review.');
|
|
127
|
+
}
|
|
128
|
+
let alive = true;
|
|
129
|
+
try { process.kill(previous.pid, 0); }
|
|
130
|
+
catch (failure) { if (failure.code === 'ESRCH') alive = false; }
|
|
131
|
+
if (alive) throw new Error('Another setup is running; retry when it finishes.');
|
|
132
|
+
const abandoned = `${lock}.${randomUUID()}.abandoned`;
|
|
133
|
+
await rename(lock, abandoned);
|
|
134
|
+
await rm(abandoned, { recursive: true, force: true });
|
|
135
|
+
try { await mkdir(lock, { mode: 0o700 }); }
|
|
136
|
+
catch { throw new Error('Another setup acquired the lock; retry when it finishes.'); }
|
|
137
|
+
}
|
|
138
|
+
await atomicWrite(join(lock, 'owner.json'), JSON.stringify(owner));
|
|
139
|
+
} finally { await rmdir(guard); }
|
|
140
|
+
return async () => {
|
|
141
|
+
const current = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
142
|
+
if (current.nonce !== owner.nonce) throw new Error('Setup lock ownership changed; it was preserved.');
|
|
143
|
+
await rm(lock, { recursive: true, force: true });
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
47
147
|
export async function ensureRuntime() {
|
|
48
148
|
const backend = join(packageRoot, 'npm/backend');
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
for (const [name, expected] of [[manifest.wheel, manifest.wheelSha256], ['requirements.txt', manifest.requirementsSha256]]) {
|
|
149
|
+
const value = manifest();
|
|
150
|
+
for (const [name, expected] of [[value.wheel, value.wheelSha256], ['requirements.txt', value.requirementsSha256]]) {
|
|
52
151
|
if (createHash('sha256').update(readFileSync(join(backend, name))).digest('hex') !== expected) throw new Error('Backend integrity check failed.');
|
|
53
152
|
}
|
|
54
|
-
const identity =
|
|
55
|
-
const runtime = join(dataRoot(), 'runtimes', identity);
|
|
56
|
-
const python = join(runtime, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
|
|
153
|
+
const identity = runtimeIdentity(value);
|
|
57
154
|
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; }
|
|
155
|
+
const release = await acquireSetupLock();
|
|
156
|
+
let preparing;
|
|
62
157
|
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 }); }
|
|
158
|
+
let python = currentPython();
|
|
159
|
+
if (!python || !healthy(python)) {
|
|
160
|
+
const uv = await uvExecutable();
|
|
161
|
+
const control = controlDirectory(identity);
|
|
162
|
+
for (const path of [join(dataRoot(), 'runtimes'), control]) {
|
|
163
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) throw new Error('Symbolic runtime directory preserved; review it locally.');
|
|
82
164
|
}
|
|
83
|
-
await mkdir(
|
|
84
|
-
|
|
85
|
-
|
|
165
|
+
await mkdir(control, { recursive: true, mode: 0o700 });
|
|
166
|
+
await atomicWrite(join(control, 'runtime-owner.json'), JSON.stringify({ kind: 'tum-mcp-runtime', schema: 2, identity }));
|
|
167
|
+
preparing = await mkdtemp(join(control, 'env-'));
|
|
168
|
+
python = pythonPath(preparing);
|
|
169
|
+
run(uv, ['venv', '--managed-python', '--python', pins.python, preparing]);
|
|
86
170
|
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,
|
|
171
|
+
run(uv, ['pip', 'install', '--python', python, '--no-deps', '--no-index', join(backend, value.wheel)]);
|
|
172
|
+
if (!healthy(python)) throw new Error('New runtime validation failed; the previous runtime was retained.');
|
|
173
|
+
await atomicWrite(join(preparing, '.tum-mcp-ready'), identity);
|
|
174
|
+
const selected = JSON.stringify({ schema: 2, identity, generation: basename(preparing), python });
|
|
175
|
+
await atomicWrite(join(control, 'current.json'), selected);
|
|
176
|
+
// Published generations are never removed: existing clients may still pin one.
|
|
177
|
+
preparing = undefined;
|
|
178
|
+
await atomicWrite(join(dataRoot(), 'runtime.json'), selected);
|
|
88
179
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
await
|
|
93
|
-
await rename(join(dataRoot(), 'runtime.pending.json'), join(dataRoot(), 'runtime.json'));
|
|
180
|
+
const selected = readFileSync(join(controlDirectory(identity), 'current.json'), 'utf8');
|
|
181
|
+
const global = join(dataRoot(), 'runtime.json');
|
|
182
|
+
if (!existsSync(global) || readFileSync(global, 'utf8') !== selected) await atomicWrite(global, selected);
|
|
183
|
+
await rm(join(dataRoot(), 'runtime-removal.pending'), { force: true });
|
|
94
184
|
return python;
|
|
95
|
-
} finally {
|
|
185
|
+
} finally {
|
|
186
|
+
try { if (preparing) await rm(preparing, { recursive: true, force: true }); }
|
|
187
|
+
finally { await release(); }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
export async function removeRuntimeFiles() {
|
|
193
|
+
const root = dataRoot();
|
|
194
|
+
const directory = join(root, 'runtimes');
|
|
195
|
+
if (!existsSync(join(root, 'runtime-removal.pending'))) throw new Error('Runtime removal must be requested with uninstall --all --purge-runtime.');
|
|
196
|
+
const owned = [];
|
|
197
|
+
if (existsSync(directory)) {
|
|
198
|
+
if (lstatSync(directory).isSymbolicLink()) throw new Error('Symbolic runtime directory preserved for review.');
|
|
199
|
+
for (const name of readdirSync(directory)) {
|
|
200
|
+
const runtime = join(directory, name);
|
|
201
|
+
if (!/^[a-f0-9]{64}$/.test(name) || lstatSync(runtime).isSymbolicLink()) throw new Error('Unrecognized runtime files were preserved for review.');
|
|
202
|
+
let verified = false;
|
|
203
|
+
const owner = join(runtime, 'runtime-owner.json');
|
|
204
|
+
if (existsSync(owner) && !lstatSync(owner).isSymbolicLink()) {
|
|
205
|
+
const record = JSON.parse(readFileSync(owner, 'utf8'));
|
|
206
|
+
verified = record.kind === 'tum-mcp-runtime' && record.schema === 2 && record.identity === name;
|
|
207
|
+
}
|
|
208
|
+
const legacy = join(runtime, '.tum-mcp-ready');
|
|
209
|
+
if (!verified && existsSync(legacy) && !lstatSync(legacy).isSymbolicLink()) verified = readFileSync(legacy, 'utf8') === name;
|
|
210
|
+
if (!verified) throw new Error('Runtime ownership could not be verified; files were preserved.');
|
|
211
|
+
owned.push(runtime);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const pointer = join(root, 'runtime.json');
|
|
215
|
+
let removePointer = false;
|
|
216
|
+
if (existsSync(pointer)) {
|
|
217
|
+
let current;
|
|
218
|
+
try { current = JSON.parse(readFileSync(pointer, 'utf8')); }
|
|
219
|
+
catch { throw new Error('Runtime selection metadata is unreadable; runtimes were preserved. Run setup to repair it.'); }
|
|
220
|
+
removePointer = /^[a-f0-9]{64}$/.test(current.identity) && owned.includes(join(directory, current.identity));
|
|
221
|
+
}
|
|
222
|
+
// All candidates are checked before deletion; never remove the campus data root.
|
|
223
|
+
for (const runtime of owned) await rm(runtime, { recursive: true });
|
|
224
|
+
if (existsSync(directory) && readdirSync(directory).length === 0) await rmdir(directory);
|
|
225
|
+
if (removePointer) await rm(pointer);
|
|
226
|
+
const uv = join(root, process.platform === 'win32' ? 'uv.exe' : 'uv');
|
|
227
|
+
const uvOwner = join(root, 'uv-owner.json');
|
|
228
|
+
if (existsSync(uvOwner) && existsSync(uv)) {
|
|
229
|
+
const expected = JSON.parse(readFileSync(uvOwner, 'utf8')).sha256;
|
|
230
|
+
if (!lstatSync(uv).isSymbolicLink() && createHash('sha256').update(readFileSync(uv)).digest('hex') === expected) {
|
|
231
|
+
await rm(uv); await rm(uvOwner);
|
|
232
|
+
} else console.error('Modified bootstrap cache was preserved for local review.');
|
|
233
|
+
}
|
|
234
|
+
const leases = join(root, 'runtime-leases');
|
|
235
|
+
if (existsSync(leases) && !lstatSync(leases).isSymbolicLink()) {
|
|
236
|
+
for (const name of readdirSync(leases)) {
|
|
237
|
+
const lock = join(leases, name);
|
|
238
|
+
if (/^[a-f0-9]{32}\.lock$/.test(name) && !lstatSync(lock).isSymbolicLink()) await rm(lock);
|
|
239
|
+
}
|
|
240
|
+
if (readdirSync(leases).length === 0) await rmdir(leases);
|
|
241
|
+
}
|
|
242
|
+
await rm(join(root, 'runtime-removal.pending'), { force: true });
|
|
243
|
+
console.log(`Removed ${owned.length} managed runtime(s). Saved user data was retained.`);
|
|
96
244
|
}
|
package/npm/tum-mcp.mjs
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { acquireSetupLock, currentPython, ensureRuntime, removeRuntimeFiles } from './runtime.mjs';
|
|
4
5
|
|
|
5
6
|
const args = process.argv.slice(2);
|
|
7
|
+
const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
6
8
|
try {
|
|
7
9
|
if (args.length === 0 || (args.length === 1 && ['--help', '-h'].includes(args[0]))) {
|
|
8
|
-
console.log(
|
|
10
|
+
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
11
|
} else if (['--version', 'version'].includes(args[0])) {
|
|
10
|
-
console.log(
|
|
12
|
+
console.log(version);
|
|
13
|
+
} else if (['setup', 'update'].includes(args[0]) && args.length === 2 && ['--help', '-h'].includes(args[1])) {
|
|
14
|
+
console.log(args[0] === 'update'
|
|
15
|
+
? 'Usage: tum-mcp update\nRefresh previously configured clients for the installed npm version.\nDownload a newer version first: npm install --global @jamestesting888/tum-mcp@latest'
|
|
16
|
+
: 'Usage: tum-mcp setup [--client codex|claude|cursor|antigravity|generic] [--host local|windows]\nProvision the managed runtime and register explicitly selected clients.');
|
|
11
17
|
} else {
|
|
18
|
+
if (args[0] === 'update' && args.length !== 1) throw new Error('Usage: tum-mcp update (no arguments). Use --help for the update workflow.');
|
|
12
19
|
const setup = ['setup', 'update'].includes(args[0]);
|
|
13
20
|
const python = setup ? await ensureRuntime() : currentPython();
|
|
14
21
|
if (!python) throw new Error('SETUP_REQUIRED: Run tum-mcp setup first.');
|
|
15
|
-
const forwarded = args
|
|
16
|
-
const env = { ...process.env }; delete env.PYTHONPATH; delete env.PYTHONHOME;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
const forwarded = args;
|
|
23
|
+
const env = { ...process.env, TUM_MCP_NPM_LAUNCHER: '1' }; delete env.PYTHONPATH; delete env.PYTHONHOME;
|
|
24
|
+
const purge = args[0] === 'uninstall' && args.includes('--purge-runtime');
|
|
25
|
+
const release = purge ? await acquireSetupLock() : null;
|
|
26
|
+
try {
|
|
27
|
+
const child = spawnSync(python, ['-I', '-m', 'tum_mcp', ...forwarded], { env, stdio: 'inherit', shell: false });
|
|
28
|
+
if (child.error) throw new Error('Managed backend could not start; run setup again.');
|
|
29
|
+
process.exitCode = child.status ?? (child.signal === 'SIGINT' ? 130 : 1);
|
|
30
|
+
if (purge && child.status === 0) await removeRuntimeFiles();
|
|
31
|
+
} finally { if (release) await release(); }
|
|
20
32
|
}
|
|
21
33
|
} catch (error) { console.error(error.message); process.exitCode = 2; }
|
package/package.json
CHANGED
|
@@ -1,16 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jamestesting888/tum-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.0",
|
|
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
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
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/uv-artifacts.json",
|
|
18
|
+
"npm/backend/*.whl",
|
|
19
|
+
"npm/backend/requirements.txt",
|
|
20
|
+
"npm/backend/manifest.json",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"NOTICE",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"README.md",
|
|
25
|
+
"docs/setup.md",
|
|
26
|
+
"docs/development.md",
|
|
27
|
+
"docs/tum-mcp.md",
|
|
28
|
+
"docs/study.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"prepack": "node npm/build.mjs",
|
|
32
|
+
"test": "node --test npm/test/*.test.mjs",
|
|
33
|
+
"test:consumer": "node npm/smoke-consumer.mjs",
|
|
34
|
+
"test:upgrade": "node npm/upgrade-consumer.mjs"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/jamestesting0/tum-mcp.git"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/jamestesting0/tum-mcp#readme",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/jamestesting0/tum-mcp/issues"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"mcp",
|
|
49
|
+
"tum",
|
|
50
|
+
"moodle",
|
|
51
|
+
"artemis",
|
|
52
|
+
"zulip",
|
|
53
|
+
"codex"
|
|
54
|
+
]
|
|
16
55
|
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
# Credential storage and setup experience
|
|
2
|
-
|
|
3
|
-
Checked against project code and primary upstream documentation on 24 October 2026.
|
|
4
|
-
|
|
5
|
-
## What other projects do
|
|
6
|
-
|
|
7
|
-
| Project | Protected default | File alternative and trade-off |
|
|
8
|
-
| --- | --- | --- |
|
|
9
|
-
| [Git Credential Manager](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md) | macOS Keychain and Windows Credential Manager; configurable Linux stores | Supports Windows DPAPI-protected files. Also offers explicitly insecure plaintext files and an ephemeral cache. |
|
|
10
|
-
| [Docker](https://docs.docker.com/reference/cli/docker/login/#credential-stores) | Native credential helpers; Docker Desktop configures one | Without a helper it stores base64-encoded credentials in configuration. Docker describes that as less secure. |
|
|
11
|
-
| [GitHub CLI](https://cli.github.com/manual/gh_auth_login) | System credential store | Supports `--insecure-storage` and documents a plain-file fallback when secure storage fails. [Its implementation](https://github.com/cli/cli/blob/trunk/internal/config/config.go#L496-L512) explicitly tracks whether insecure storage was used. |
|
|
12
|
-
| [Electron](https://www.electronjs.org/docs/latest/api/safe-storage) | macOS Keychain; Windows DPAPI; Linux desktop secret stores | macOS can still prompt. Its documentation recommends consistent code signing to prevent repeated approval after updates. Linux fallback backends need careful inspection because protection can be weaker. |
|
|
13
|
-
|
|
14
|
-
Saving a key elsewhere is technically possible, but location alone does not
|
|
15
|
-
protect it. A readable key file next to encrypted sessions lets a process that
|
|
16
|
-
can copy both decrypt the sessions. Restrictive file permissions help against
|
|
17
|
-
other OS users; they do not provide a separate secret-store boundary against
|
|
18
|
-
code running as the same user. This is why the protected defaults above still
|
|
19
|
-
use operating-system services.
|
|
20
|
-
|
|
21
|
-
## Current TUM MCP implementation
|
|
22
|
-
|
|
23
|
-
| Platform | Implemented storage | Verification and expected setup |
|
|
24
|
-
| --- | --- | --- |
|
|
25
|
-
| macOS | Native Keychain item holds the encryption key; service cookies remain in encrypted files | Real saved sessions and scoped reads passed from the managed runtime after the user approved access. OS prompts remain possible for locked items, changed application identity, or temporary approval. |
|
|
26
|
-
| Windows | Windows Credential Locker through Python keyring | Implementation and CI configuration exist. Hands-on Windows verification is still pending; no claim of universally prompt-free operation is made. |
|
|
27
|
-
| Linux | Freedesktop Secret Service through Python keyring | ARM64 and x86_64 container bootstrap passed. On x86_64, an encrypted credential round trip across separate processes passed through real GNOME Secret Service in an isolated D-Bus session. Real desktop browser sign-in remains open. A usable, unlocked service and D-Bus session are required. |
|
|
28
|
-
|
|
29
|
-
The backend selection is in `tum_moodle.auth.system_keyring`; encryption and
|
|
30
|
-
account binding are in `tum_mcp.credentials.Vault`. There is no automatic
|
|
31
|
-
plaintext fallback. The data-encryption key's service name and storage location
|
|
32
|
-
are stable across backend-wheel updates.
|
|
33
|
-
|
|
34
|
-
Windows can also protect a file directly with per-user
|
|
35
|
-
[DPAPI](https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata).
|
|
36
|
-
That avoids an adjacent unprotected key file. It is a reasonable optional backend
|
|
37
|
-
if Credential Manager proves unsuitable, but it is not implemented in this build.
|
|
38
|
-
It does not remove the need for a usable Windows user/logon context.
|
|
39
|
-
|
|
40
|
-
## Why this setup prompted repeatedly
|
|
41
|
-
|
|
42
|
-
The initial account connection ran under development Python. The installed
|
|
43
|
-
package then used managed Python to access the same existing Keychain item.
|
|
44
|
-
Those were different requesting executables. Python keyring documents that
|
|
45
|
-
access is associated with the
|
|
46
|
-
[Python executable](https://keyring.readthedocs.io/en/stable/#security-considerations).
|
|
47
|
-
Temporary **Allow** and persistent **Always Allow** are also different choices.
|
|
48
|
-
|
|
49
|
-
The fresh-install path was tested separately with a disposable native Keychain
|
|
50
|
-
item: managed Python created it, then a second process of that same runtime read
|
|
51
|
-
it successfully in about one second without another user interaction. The item
|
|
52
|
-
was deleted afterward. This supports using the installed runtime from the first
|
|
53
|
-
login; it does not guarantee every Mac policy will suppress all prompts.
|
|
54
|
-
|
|
55
|
-
A subsequent real MCP check passed through the installed Codex plugin, its Node
|
|
56
|
-
launcher, and managed Python: both provider identities verified, the selected
|
|
57
|
-
Zulip channel was readable, and an unselected channel was denied. No remote
|
|
58
|
-
submission or message write was performed.
|
|
59
|
-
|
|
60
|
-
## Recommended direction
|
|
61
|
-
|
|
62
|
-
1. Keep native protected storage as the default and use the installed `tum-mcp`
|
|
63
|
-
command for both onboarding and normal use.
|
|
64
|
-
2. Add a foreground storage-access check before opening browser login, so any
|
|
65
|
-
necessary OS approval happens during setup rather than a later tool call.
|
|
66
|
-
This is a proposed UX improvement, not a shipped behavior change here.
|
|
67
|
-
3. If future macOS interpreter updates keep changing identity, use a dedicated,
|
|
68
|
-
consistently signed credential helper. That needs separate packaging/signing
|
|
69
|
-
work and cannot guarantee that first-time OS approval never appears.
|
|
70
|
-
4. Consider explicit DPAPI-file or session-only modes for their appropriate
|
|
71
|
-
platforms/use cases. Plain-file storage should require an informed choice;
|
|
72
|
-
session-only storage requires signing in again after the broker stops.
|
|
73
|
-
|
|
74
|
-
These recommendations change neither the existing stored credentials nor their
|
|
75
|
-
permissions. Keychain approval is scoped to Python's access to this item; scripts
|
|
76
|
-
using the same trusted executable can also request it. A restricted MCP API is
|
|
77
|
-
not a system-wide sandbox for an otherwise unrestricted coding agent.
|
|
78
|
-
|
|
79
|
-
## Separate desktop-app permission
|
|
80
|
-
|
|
81
|
-
The macOS prompt naming **ChatGPT** and asking to access data from other apps is
|
|
82
|
-
separate from a Keychain request for `tum-mcp`. Apple associates that prompt with
|
|
83
|
-
[files in other apps' sandbox containers](https://developer.apple.com/documentation/BundleResources/Information-Property-List/NSAppDataUsageDescription).
|
|
84
|
-
The campus backend's default storage and HTTP read paths do not need that broad
|
|
85
|
-
host permission. The specific container behind the observed prompt was not
|
|
86
|
-
identified; computer-use inventory is a possible trigger, not a confirmed cause.
|
|
Binary file
|