moodle-cli 0.7.0-alpha.4 → 0.7.0-alpha.6
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 +3 -1
- package/SKILL.md +1 -1
- package/agents/openai.yaml +2 -2
- package/dist/moodle.js +578 -65
- package/dist/worker/worker.js +1255 -839
- package/package.json +1 -1
- package/references/downloads.md +5 -1
package/dist/moodle.js
CHANGED
|
@@ -222,8 +222,16 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
|
|
|
222
222
|
if (cached) {
|
|
223
223
|
return cached;
|
|
224
224
|
}
|
|
225
|
+
const cookieWarnings = [];
|
|
226
|
+
const providerOptions = {
|
|
227
|
+
...options,
|
|
228
|
+
onCookieWarnings: (warnings) => {
|
|
229
|
+
cookieWarnings.push(...warnings);
|
|
230
|
+
options.onCookieWarnings?.(warnings);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
225
233
|
const browserProvider = options.browserCookieProvider ?? defaultBrowserCookieProvider;
|
|
226
|
-
const browserCookies = matchingMoodleSessionCookies(await browserProvider(baseUrl,
|
|
234
|
+
const browserCookies = matchingMoodleSessionCookies(await browserProvider(baseUrl, providerOptions), baseUrl);
|
|
227
235
|
const browserSession = await firstValidSession(baseUrl, browserCookies, validate);
|
|
228
236
|
if (browserSession) {
|
|
229
237
|
await refreshSessionCache(baseUrl, browserSession.cookie, browserSession.context, options);
|
|
@@ -247,10 +255,22 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
|
|
|
247
255
|
return { baseUrl, cookie: refreshedSession.cookie, ...refreshedSession.context, fromCache: false };
|
|
248
256
|
}
|
|
249
257
|
}
|
|
250
|
-
throw new AuthError(
|
|
258
|
+
throw new AuthError(
|
|
259
|
+
`No usable MoodleSession found for ${baseUrl}.`,
|
|
260
|
+
authFailureHint(baseUrl, cookieWarnings, options.platform)
|
|
261
|
+
);
|
|
251
262
|
}
|
|
252
263
|
async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {}) {
|
|
253
|
-
const
|
|
264
|
+
const cookieWarnings = [];
|
|
265
|
+
const authOptions = {
|
|
266
|
+
...options,
|
|
267
|
+
noCache: true,
|
|
268
|
+
nonInteractive: true,
|
|
269
|
+
onCookieWarnings: (warnings) => {
|
|
270
|
+
cookieWarnings.push(...warnings);
|
|
271
|
+
options.onCookieWarnings?.(warnings);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
254
274
|
const browserAuthOptions = {
|
|
255
275
|
...authOptions,
|
|
256
276
|
env: { ...options.env ?? process.env, [ENV_MOODLE_SESSION]: void 0 }
|
|
@@ -271,6 +291,12 @@ async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {})
|
|
|
271
291
|
}
|
|
272
292
|
}
|
|
273
293
|
}
|
|
294
|
+
if (cookieAccessBlocked(cookieWarnings)) {
|
|
295
|
+
throw new AuthError(
|
|
296
|
+
`Cannot read browser cookies for ${baseUrl}.`,
|
|
297
|
+
cookieAccessHint(cookieWarnings, options.platform)
|
|
298
|
+
);
|
|
299
|
+
}
|
|
274
300
|
const url = loginUrl(baseUrl);
|
|
275
301
|
await (options.openBrowser ?? ((target) => openSystemBrowser(target, options)))(url);
|
|
276
302
|
options.onBrowserOpened?.(url);
|
|
@@ -330,7 +356,11 @@ async function defaultBrowserCookieProvider(baseUrl, options = {}) {
|
|
|
330
356
|
mode: "merge"
|
|
331
357
|
});
|
|
332
358
|
const braveProfiles = await braveProfilePaths(options);
|
|
333
|
-
const brave = braveProfiles.length ? await getCookies({ url: baseUrl, browsers: ["chrome"], chromeProfile: braveProfiles, mode: "merge" }) : { cookies: [] };
|
|
359
|
+
const brave = braveProfiles.length ? await getCookies({ url: baseUrl, browsers: ["chrome"], chromeProfile: braveProfiles, mode: "merge" }) : { cookies: [], warnings: [] };
|
|
360
|
+
const warnings = [...primary.warnings ?? [], ...brave.warnings ?? []];
|
|
361
|
+
if (warnings.length) {
|
|
362
|
+
options.onCookieWarnings?.(warnings);
|
|
363
|
+
}
|
|
334
364
|
return [...primary.cookies, ...brave.cookies].map((cookie) => ({
|
|
335
365
|
name: cookie.name,
|
|
336
366
|
value: cookie.value,
|
|
@@ -345,7 +375,7 @@ async function braveProfilePaths(options = {}) {
|
|
|
345
375
|
const roots = platform === "linux" ? [
|
|
346
376
|
join2(home, ".config/BraveSoftware/Brave-Browser"),
|
|
347
377
|
join2(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser")
|
|
348
|
-
] : platform === "win32" ? [join2(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : [];
|
|
378
|
+
] : platform === "win32" ? [join2(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : platform === "darwin" ? [join2(home, "Library/Application Support/BraveSoftware/Brave-Browser")] : [];
|
|
349
379
|
const profiles = [];
|
|
350
380
|
for (const root of roots) {
|
|
351
381
|
try {
|
|
@@ -375,13 +405,36 @@ async function loadSessionsFromOktaCli(baseUrl, options = {}, forceLogin = false
|
|
|
375
405
|
const refreshed = await readOktaCookies(executable, baseUrl, execFile2);
|
|
376
406
|
return refreshed.length ? refreshed : stored;
|
|
377
407
|
}
|
|
378
|
-
|
|
408
|
+
var COOKIE_ACCESS_DENIED = /EPERM|EACCES|operation not permitted|permission denied/i;
|
|
409
|
+
function cookieAccessBlocked(warnings) {
|
|
410
|
+
return warnings.some((warning) => COOKIE_ACCESS_DENIED.test(warning));
|
|
411
|
+
}
|
|
412
|
+
function cookieAccessHint(warnings, platform = process.platform) {
|
|
413
|
+
const grant = platform === "darwin" ? "Grant Full Disk Access to the application running this command (System Settings > Privacy & Security > Full Disk Access), then restart it." : "Run this command as the user that owns the browser profile, or grant it read access to the browser cookie store.";
|
|
379
414
|
return [
|
|
415
|
+
"The browser cookie store could not be read, so the session could not be detected.",
|
|
416
|
+
"If this runs inside a sandboxed app (an IDE or agent terminal), rerun it from a regular terminal first.",
|
|
417
|
+
grant,
|
|
418
|
+
`Alternatively set ${ENV_MOODLE_SESSION} to a valid MoodleSession cookie value.`,
|
|
419
|
+
"",
|
|
420
|
+
"Cookie store diagnostics:",
|
|
421
|
+
...warnings.map((warning) => ` - ${warning}`)
|
|
422
|
+
].join("\n");
|
|
423
|
+
}
|
|
424
|
+
function authFailureHint(baseUrl, cookieWarnings = [], platform = process.platform) {
|
|
425
|
+
if (cookieAccessBlocked(cookieWarnings)) {
|
|
426
|
+
return cookieAccessHint(cookieWarnings, platform);
|
|
427
|
+
}
|
|
428
|
+
const lines = [
|
|
380
429
|
`Log in to ${loginUrl(baseUrl)} in your browser, then rerun the command.`,
|
|
381
430
|
`Or set ${ENV_MOODLE_SESSION} to a valid MoodleSession cookie value.`,
|
|
382
431
|
`For automatic login, install okta-auth: ${OKTA_AUTH_INSTALL_COMMAND}, then run ${OKTA_AUTH_CONFIG_COMMAND}.`,
|
|
383
432
|
`okta-auth: ${OKTA_AUTH_URL}`
|
|
384
|
-
]
|
|
433
|
+
];
|
|
434
|
+
if (cookieWarnings.length) {
|
|
435
|
+
lines.push("", "Cookie store diagnostics:", ...cookieWarnings.map((warning) => ` - ${warning}`));
|
|
436
|
+
}
|
|
437
|
+
return lines.join("\n");
|
|
385
438
|
}
|
|
386
439
|
async function invalidateCachedSession(baseUrl, options = {}) {
|
|
387
440
|
await deleteCachedSession(baseUrl, cacheOptions(options));
|
|
@@ -1010,9 +1063,9 @@ function parseCourseSectionNumbers(html, courseId) {
|
|
|
1010
1063
|
for (const href of hrefs) {
|
|
1011
1064
|
const url = parseMaybeUrl(href.replace(/&/gu, "&"), "https://moodle.invalid");
|
|
1012
1065
|
const id = url?.searchParams.get("id");
|
|
1013
|
-
const
|
|
1014
|
-
if (id === String(courseId) &&
|
|
1015
|
-
const section = Number(
|
|
1066
|
+
const sectionValue2 = url?.searchParams.get("section");
|
|
1067
|
+
if (id === String(courseId) && sectionValue2 && /^\d+$/.test(sectionValue2)) {
|
|
1068
|
+
const section = Number(sectionValue2);
|
|
1016
1069
|
if (!sections.includes(section)) {
|
|
1017
1070
|
sections.push(section);
|
|
1018
1071
|
}
|
|
@@ -2286,8 +2339,8 @@ var MoodleClientCore = class {
|
|
|
2286
2339
|
}
|
|
2287
2340
|
if (!response.ok) {
|
|
2288
2341
|
const context = `HTTP ${response.status} loading ${safeUrl(url)}`;
|
|
2289
|
-
const
|
|
2290
|
-
if (
|
|
2342
|
+
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
2343
|
+
if (contentType3.includes("text/html") || contentType3.includes("application/xhtml+xml")) {
|
|
2291
2344
|
const moodleError = await response.text().then(parseMoodleErrorHtml).catch(() => null);
|
|
2292
2345
|
if (moodleError) {
|
|
2293
2346
|
throw this.errors.api(`${moodleError.message} (${context})`, moodleError.code);
|
|
@@ -2669,8 +2722,8 @@ async function probeBaseUrl(baseUrl, options = {}) {
|
|
|
2669
2722
|
return { ok: false, message: `Could not reach ${baseUrl}: ${error instanceof Error ? error.message : String(error)}` };
|
|
2670
2723
|
}
|
|
2671
2724
|
const body = (await response.text()).slice(0, 5e3).toLowerCase();
|
|
2672
|
-
const
|
|
2673
|
-
const looksJson =
|
|
2725
|
+
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
2726
|
+
const looksJson = contentType3.includes("application/json") || body.startsWith("{");
|
|
2674
2727
|
const looksMoodleTokenError = [
|
|
2675
2728
|
'"errorcode":"missingparam"',
|
|
2676
2729
|
'"errorcode":"invalidparameter"',
|
|
@@ -3745,8 +3798,8 @@ function keepaliveProgramArguments(execPath = process.execPath, argv1 = process.
|
|
|
3745
3798
|
}
|
|
3746
3799
|
return [execPath, resolvedArgv1, ...tail];
|
|
3747
3800
|
}
|
|
3748
|
-
function buildKeepalivePlist(
|
|
3749
|
-
const args =
|
|
3801
|
+
function buildKeepalivePlist(programArguments2, intervalMinutes, logPath) {
|
|
3802
|
+
const args = programArguments2.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
|
|
3750
3803
|
return [
|
|
3751
3804
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
3752
3805
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
@@ -3827,7 +3880,7 @@ function escapeXml(value) {
|
|
|
3827
3880
|
}
|
|
3828
3881
|
|
|
3829
3882
|
// src/version.ts
|
|
3830
|
-
var VERSION = "0.7.0-alpha.
|
|
3883
|
+
var VERSION = "0.7.0-alpha.6";
|
|
3831
3884
|
|
|
3832
3885
|
// src/forum.ts
|
|
3833
3886
|
function parseDiscussionReference(value) {
|
|
@@ -3861,11 +3914,11 @@ async function parseForumReference(value, resolveForumCmid) {
|
|
|
3861
3914
|
throw new UsageError("FORUM must be a numeric ID or a full forum URL.");
|
|
3862
3915
|
}
|
|
3863
3916
|
if (url.pathname.endsWith("/mod/forum/view.php")) {
|
|
3864
|
-
const
|
|
3865
|
-
if (!
|
|
3917
|
+
const forumValue2 = url.searchParams.get("id");
|
|
3918
|
+
if (!forumValue2 || !/^\d+$/.test(forumValue2)) {
|
|
3866
3919
|
throw new UsageError("Could not find forum module ID in view.php URL (expected ?id=...).");
|
|
3867
3920
|
}
|
|
3868
|
-
return Number(
|
|
3921
|
+
return Number(forumValue2);
|
|
3869
3922
|
}
|
|
3870
3923
|
if (url.pathname.endsWith("/mod/forum/discuss.php")) {
|
|
3871
3924
|
const discussionValue = url.searchParams.get("d");
|
|
@@ -4125,11 +4178,12 @@ async function bridgeRemoteMcp(options) {
|
|
|
4125
4178
|
}
|
|
4126
4179
|
if (response.status === 202 || request.id === void 0) return;
|
|
4127
4180
|
if (!response.ok) {
|
|
4181
|
+
if (await forwardProtocolNegotiationError(options.output, response, request.id)) return;
|
|
4128
4182
|
await writeRemoteError(options.output, request.id, response.status);
|
|
4129
4183
|
return;
|
|
4130
4184
|
}
|
|
4131
|
-
const
|
|
4132
|
-
if (
|
|
4185
|
+
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
4186
|
+
if (contentType3.includes("text/event-stream")) {
|
|
4133
4187
|
const forwarded = await writeSseMessages(options.output, await response.text(), request.id);
|
|
4134
4188
|
if (forwarded) negotiatedProtocolVersion = initializedProtocolVersion(request) ?? negotiatedProtocolVersion;
|
|
4135
4189
|
return;
|
|
@@ -4204,6 +4258,30 @@ async function writeRemoteError(output, id, status) {
|
|
|
4204
4258
|
data: { type: "REMOTE_MCP_ERROR", status }
|
|
4205
4259
|
}));
|
|
4206
4260
|
}
|
|
4261
|
+
async function forwardProtocolNegotiationError(output, response, id) {
|
|
4262
|
+
if (response.status !== 400 || !response.headers.get("content-type")?.toLowerCase().includes("application/json")) {
|
|
4263
|
+
return false;
|
|
4264
|
+
}
|
|
4265
|
+
try {
|
|
4266
|
+
const payload = await response.json();
|
|
4267
|
+
if (!isRecord7(payload) || payload.jsonrpc !== "2.0" || payload.id !== id || !isRecord7(payload.error)) {
|
|
4268
|
+
return false;
|
|
4269
|
+
}
|
|
4270
|
+
const data = isRecord7(payload.error.data) ? payload.error.data : void 0;
|
|
4271
|
+
const supported = Array.isArray(data?.supported) ? data.supported.filter((version) => typeof version === "string") : [];
|
|
4272
|
+
if (payload.error.code !== -32022 || typeof data?.requested !== "string" || supported.length === 0) {
|
|
4273
|
+
return false;
|
|
4274
|
+
}
|
|
4275
|
+
await writeJson(output, jsonRpcFailure(id, {
|
|
4276
|
+
code: -32022,
|
|
4277
|
+
message: "Unsupported protocol version",
|
|
4278
|
+
data: { supported, requested: data.requested }
|
|
4279
|
+
}));
|
|
4280
|
+
return true;
|
|
4281
|
+
} catch {
|
|
4282
|
+
return false;
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4207
4285
|
async function writeJson(output, value) {
|
|
4208
4286
|
await output.write(`${JSON.stringify(value)}
|
|
4209
4287
|
`);
|
|
@@ -4387,7 +4465,7 @@ function jsonCodec(container) {
|
|
|
4387
4465
|
};
|
|
4388
4466
|
}
|
|
4389
4467
|
function jsonRegistration(connection) {
|
|
4390
|
-
return connection.mode === "bridge" ? { command: connection.command, args:
|
|
4468
|
+
return connection.mode === "bridge" ? { command: connection.command, args: connection.args } : {
|
|
4391
4469
|
type: "http",
|
|
4392
4470
|
url: connection.endpoint,
|
|
4393
4471
|
headers: { Authorization: `Bearer ${connection.accessToken}` }
|
|
@@ -4401,7 +4479,7 @@ function tomlBlock(registration, connection) {
|
|
|
4401
4479
|
if (connection.mode === "bridge") {
|
|
4402
4480
|
lines.push(
|
|
4403
4481
|
`command = ${JSON.stringify(connection.command)}`,
|
|
4404
|
-
`args = ${JSON.stringify(
|
|
4482
|
+
`args = ${JSON.stringify(connection.args)}`
|
|
4405
4483
|
);
|
|
4406
4484
|
} else {
|
|
4407
4485
|
lines.push(
|
|
@@ -4453,7 +4531,11 @@ function validateProfile(profile) {
|
|
|
4453
4531
|
}
|
|
4454
4532
|
function resolveConnection(options) {
|
|
4455
4533
|
if (options.mode !== "remote") {
|
|
4456
|
-
return {
|
|
4534
|
+
return {
|
|
4535
|
+
mode: "bridge",
|
|
4536
|
+
command: options.command ?? "moodle",
|
|
4537
|
+
args: [...options.commandArgs ?? [], "mcp", "bridge", "--profile", options.profile]
|
|
4538
|
+
};
|
|
4457
4539
|
}
|
|
4458
4540
|
if (!options.endpoint || !options.accessToken) {
|
|
4459
4541
|
throw new Error("Remote MCP connection requires an endpoint and access token");
|
|
@@ -4473,6 +4555,15 @@ function resolveConnection(options) {
|
|
|
4473
4555
|
return { mode: "remote", endpoint: endpoint.toString(), accessToken: options.accessToken };
|
|
4474
4556
|
}
|
|
4475
4557
|
|
|
4558
|
+
// src/mcp/self-command.ts
|
|
4559
|
+
function selfCommand(argv = process.argv, execPath = process.execPath) {
|
|
4560
|
+
const script = argv[1];
|
|
4561
|
+
return script && script !== execPath ? { command: execPath, args: [script] } : { command: execPath, args: [] };
|
|
4562
|
+
}
|
|
4563
|
+
function runtimeCommand(command, args) {
|
|
4564
|
+
return command ? { command, args: [...args ?? []] } : selfCommand();
|
|
4565
|
+
}
|
|
4566
|
+
|
|
4476
4567
|
// src/mcp/connectors/node-connectors.ts
|
|
4477
4568
|
import { chmod as chmod2, mkdir as mkdir4, readFile as readFile3, rm as rm3, stat as stat2, writeFile as writeFile4 } from "fs/promises";
|
|
4478
4569
|
import { homedir as homedir5 } from "os";
|
|
@@ -4505,9 +4596,11 @@ function createDefaultClientConnectors(profile, options = {}) {
|
|
|
4505
4596
|
const home = options.homeDirectory ?? homedir5();
|
|
4506
4597
|
const platform = options.platform ?? process.platform;
|
|
4507
4598
|
const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
|
|
4599
|
+
const runtime = runtimeCommand(options.command, options.commandArgs);
|
|
4508
4600
|
const shared = {
|
|
4509
4601
|
profile,
|
|
4510
|
-
command:
|
|
4602
|
+
command: runtime.command,
|
|
4603
|
+
commandArgs: runtime.args,
|
|
4511
4604
|
mode: options.mode,
|
|
4512
4605
|
endpoint: options.endpoint,
|
|
4513
4606
|
accessToken: options.accessToken
|
|
@@ -5367,7 +5460,8 @@ var ManagedMcpDeployment = class {
|
|
|
5367
5460
|
await this.dependencies.wrangler.promote({
|
|
5368
5461
|
accountId: plan.intent.accountId,
|
|
5369
5462
|
workerName: plan.intent.workerName,
|
|
5370
|
-
versionId: candidate.versionId
|
|
5463
|
+
versionId: candidate.versionId,
|
|
5464
|
+
releaseDigest: plan.intent.releaseDigest
|
|
5371
5465
|
});
|
|
5372
5466
|
promoted = true;
|
|
5373
5467
|
}
|
|
@@ -5409,7 +5503,8 @@ var ManagedMcpDeployment = class {
|
|
|
5409
5503
|
await this.dependencies.wrangler.promote({
|
|
5410
5504
|
accountId: plan.intent.accountId,
|
|
5411
5505
|
workerName: plan.intent.workerName,
|
|
5412
|
-
versionId: candidate.versionId
|
|
5506
|
+
versionId: candidate.versionId,
|
|
5507
|
+
releaseDigest: plan.intent.releaseDigest
|
|
5413
5508
|
});
|
|
5414
5509
|
promoted = true;
|
|
5415
5510
|
}
|
|
@@ -5518,7 +5613,7 @@ var ManagedMcpDeployment = class {
|
|
|
5518
5613
|
readinessReasonCode = remoteReadiness.reasonCode;
|
|
5519
5614
|
sessionRevision = remoteReadiness.revision;
|
|
5520
5615
|
}
|
|
5521
|
-
const resolvedWorker = worker ? { ...worker, productionEndpoint: receipt.productionEndpoint } : null;
|
|
5616
|
+
const resolvedWorker = worker ? { ...worker, productionEndpoint: receipt.productionEndpoint, releaseDigest: worker.releaseDigest || receipt.releaseDigest } : null;
|
|
5522
5617
|
return {
|
|
5523
5618
|
profile,
|
|
5524
5619
|
worker: resolvedWorker,
|
|
@@ -5920,14 +6015,13 @@ function macOSPlan(options, intervalMinutes) {
|
|
|
5920
6015
|
const label = `com.moodle-cli.mcp-renewal.${options.profile}`;
|
|
5921
6016
|
const path4 = `${trimEnd(options.homeDirectory, "/")}/Library/LaunchAgents/${label}.plist`;
|
|
5922
6017
|
const target = `gui/${options.uid}`;
|
|
5923
|
-
const args = renewalArgs(options.profile);
|
|
5924
6018
|
const plist = [
|
|
5925
6019
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
5926
6020
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
5927
6021
|
'<plist version="1.0"><dict>',
|
|
5928
6022
|
`<key>Label</key><string>${xml(label)}</string>`,
|
|
5929
6023
|
"<key>ProgramArguments</key><array>",
|
|
5930
|
-
...
|
|
6024
|
+
...programArguments(options).map((arg) => `<string>${xml(arg)}</string>`),
|
|
5931
6025
|
"</array>",
|
|
5932
6026
|
`<key>StartInterval</key><integer>${intervalMinutes * 60}</integer>`,
|
|
5933
6027
|
"<key>RunAtLoad</key><true/>",
|
|
@@ -5951,7 +6045,7 @@ function linuxPlan(options, intervalMinutes) {
|
|
|
5951
6045
|
const directory = `${trimEnd(options.homeDirectory, "/")}/.config/systemd/user`;
|
|
5952
6046
|
const servicePath = `${directory}/${label}.service`;
|
|
5953
6047
|
const timerPath = `${directory}/${label}.timer`;
|
|
5954
|
-
const command =
|
|
6048
|
+
const command = programArguments(options).map(systemdQuote).join(" ");
|
|
5955
6049
|
const service = [
|
|
5956
6050
|
"[Unit]",
|
|
5957
6051
|
`Description=Moodle MCP session renewal (${options.profile})`,
|
|
@@ -5995,7 +6089,7 @@ function linuxPlan(options, intervalMinutes) {
|
|
|
5995
6089
|
function windowsPlan(options, intervalMinutes) {
|
|
5996
6090
|
const label = `Moodle CLI MCP Renewal (${options.profile})`;
|
|
5997
6091
|
const path4 = `${trimEnd(options.homeDirectory, "\\/")}\\AppData\\Local\\moodle-cli\\renewal\\${options.profile}.xml`;
|
|
5998
|
-
const argumentsText = renewalArgs(options.profile).map(windowsArgument).join(" ");
|
|
6092
|
+
const argumentsText = [...options.executableArgs ?? [], ...renewalArgs(options.profile)].map(windowsArgument).join(" ");
|
|
5999
6093
|
const task = [
|
|
6000
6094
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
6001
6095
|
'<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
|
|
@@ -6022,6 +6116,9 @@ function windowsPlan(options, intervalMinutes) {
|
|
|
6022
6116
|
function renewalArgs(profile) {
|
|
6023
6117
|
return ["mcp", "renewal", "run", "--profile", profile, "--json"];
|
|
6024
6118
|
}
|
|
6119
|
+
function programArguments(options) {
|
|
6120
|
+
return [options.executable, ...options.executableArgs ?? [], ...renewalArgs(options.profile)];
|
|
6121
|
+
}
|
|
6025
6122
|
function validateOptions(options) {
|
|
6026
6123
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(options.profile)) {
|
|
6027
6124
|
throw new Error("Invalid renewal profile name");
|
|
@@ -6029,6 +6126,9 @@ function validateOptions(options) {
|
|
|
6029
6126
|
if (!options.executable || /[\r\n]/.test(options.executable)) {
|
|
6030
6127
|
throw new Error("Invalid renewal executable path");
|
|
6031
6128
|
}
|
|
6129
|
+
if ((options.executableArgs ?? []).some((arg) => /[\r\n]/.test(arg))) {
|
|
6130
|
+
throw new Error("Invalid renewal executable arguments");
|
|
6131
|
+
}
|
|
6032
6132
|
if (!options.homeDirectory || /[\r\n]/.test(options.homeDirectory)) {
|
|
6033
6133
|
throw new Error("Invalid renewal home directory");
|
|
6034
6134
|
}
|
|
@@ -6103,10 +6203,12 @@ function createDefaultRenewalInstaller(profile, options = {}) {
|
|
|
6103
6203
|
if (!isSupportedPlatform(platform)) {
|
|
6104
6204
|
throw new Error(`Moodle MCP renewal is not supported on ${platform}`);
|
|
6105
6205
|
}
|
|
6206
|
+
const runtime = runtimeCommand(options.executable, options.executableArgs);
|
|
6106
6207
|
const plan = buildRenewalInstallPlan({
|
|
6107
6208
|
platform,
|
|
6108
6209
|
profile,
|
|
6109
|
-
executable:
|
|
6210
|
+
executable: runtime.command,
|
|
6211
|
+
executableArgs: runtime.args,
|
|
6110
6212
|
homeDirectory: options.homeDirectory ?? homedir7(),
|
|
6111
6213
|
uid: options.uid ?? (typeof process.getuid === "function" ? process.getuid() : void 0),
|
|
6112
6214
|
intervalMinutes: options.intervalMinutes
|
|
@@ -6314,7 +6416,9 @@ ${error.stderr}`)) {
|
|
|
6314
6416
|
throw new DeploymentApplyError("INITIAL_WORKER_INVALID", "Wrangler did not return the initialized Worker");
|
|
6315
6417
|
}
|
|
6316
6418
|
const productionEndpoint = firstWorkersDevUrl([result.stdout, result.stderr]);
|
|
6317
|
-
|
|
6419
|
+
const initialized = productionEndpoint ? { ...worker, productionEndpoint } : worker;
|
|
6420
|
+
await pinExpectedHosts(input.configPath, input.workerName, initialized.productionEndpoint);
|
|
6421
|
+
return initialized;
|
|
6318
6422
|
} catch (error) {
|
|
6319
6423
|
const worker = await this.inspect(input.accountId, input.workerName).catch(() => null);
|
|
6320
6424
|
if (worker || result) {
|
|
@@ -6359,6 +6463,8 @@ ${error.stderr}`)) {
|
|
|
6359
6463
|
await rm6(outputFilePath, { force: true });
|
|
6360
6464
|
}
|
|
6361
6465
|
}
|
|
6466
|
+
// restoreProduction re-deploys an older version whose digest is unknown, so the
|
|
6467
|
+
// release annotation is only written when the caller knows it.
|
|
6362
6468
|
async promote(input) {
|
|
6363
6469
|
await this.wrangler([
|
|
6364
6470
|
"versions",
|
|
@@ -6366,7 +6472,8 @@ ${error.stderr}`)) {
|
|
|
6366
6472
|
`${input.versionId}@100`,
|
|
6367
6473
|
"--name",
|
|
6368
6474
|
input.workerName,
|
|
6369
|
-
"--yes"
|
|
6475
|
+
"--yes",
|
|
6476
|
+
...input.releaseDigest ? ["--message", `moodle-cli-release:${input.releaseDigest}`] : []
|
|
6370
6477
|
], input.accountId);
|
|
6371
6478
|
}
|
|
6372
6479
|
async restoreProduction(input) {
|
|
@@ -6412,6 +6519,7 @@ var NodeReleaseMaterializer = class {
|
|
|
6412
6519
|
await copyFile(this.options.workerBundlePath, workerFile);
|
|
6413
6520
|
const wranglerConfigPath = join7(artifactDirectory, "wrangler.json");
|
|
6414
6521
|
const secretsFilePath = join7(artifactDirectory, "secrets.json");
|
|
6522
|
+
const expectedHosts = endpointHosts(plan.intent.workerName, plan.existing?.productionEndpoint);
|
|
6415
6523
|
const config = {
|
|
6416
6524
|
$schema: "node_modules/wrangler/config-schema.json",
|
|
6417
6525
|
name: plan.intent.workerName,
|
|
@@ -6419,7 +6527,10 @@ var NodeReleaseMaterializer = class {
|
|
|
6419
6527
|
main: `./${basename(workerFile)}`,
|
|
6420
6528
|
compatibility_date: this.options.compatibilityDate,
|
|
6421
6529
|
preview_urls: true,
|
|
6422
|
-
vars: {
|
|
6530
|
+
vars: {
|
|
6531
|
+
MOODLE_ORIGIN: plan.intent.moodleOrigin,
|
|
6532
|
+
...expectedHosts.length ? { EXPECTED_HOSTS: expectedHosts.join(",") } : {}
|
|
6533
|
+
},
|
|
6423
6534
|
durable_objects: {
|
|
6424
6535
|
bindings: [{ name: "SESSION_BROKER", class_name: "SessionBroker" }]
|
|
6425
6536
|
},
|
|
@@ -6626,7 +6737,7 @@ var PrivateDeploymentReceiptStore = class {
|
|
|
6626
6737
|
function createDefaultManagedDeployment(options) {
|
|
6627
6738
|
const homeDirectory = options.homeDirectory ?? homedir8();
|
|
6628
6739
|
const platform = options.platform ?? process.platform;
|
|
6629
|
-
const
|
|
6740
|
+
const runtime = runtimeCommand(options.executable, options.executableArgs);
|
|
6630
6741
|
const defaults = {
|
|
6631
6742
|
wrangler: new NodeWranglerDeploymentAdapter({ wranglerBinPath: options.wranglerBinPath }),
|
|
6632
6743
|
materializer: new NodeReleaseMaterializer({
|
|
@@ -6640,14 +6751,16 @@ function createDefaultManagedDeployment(options) {
|
|
|
6640
6751
|
...options.renewal,
|
|
6641
6752
|
platform,
|
|
6642
6753
|
homeDirectory,
|
|
6643
|
-
executable,
|
|
6754
|
+
executable: runtime.command,
|
|
6755
|
+
executableArgs: runtime.args,
|
|
6644
6756
|
uid: options.uid
|
|
6645
6757
|
}),
|
|
6646
6758
|
clients: new DefaultClientIntegration({
|
|
6647
6759
|
...options.connector,
|
|
6648
6760
|
platform,
|
|
6649
6761
|
homeDirectory,
|
|
6650
|
-
command:
|
|
6762
|
+
command: runtime.command,
|
|
6763
|
+
commandArgs: runtime.args
|
|
6651
6764
|
}),
|
|
6652
6765
|
receipts: new PrivateDeploymentReceiptStore(join7(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
|
|
6653
6766
|
createToken: () => randomBytes(32).toString("base64url")
|
|
@@ -6671,6 +6784,35 @@ function ownershipId(accountId, workerName) {
|
|
|
6671
6784
|
function endpointUrl(endpoint, path4) {
|
|
6672
6785
|
return `${endpoint.replace(/\/$/u, "")}${path4}`;
|
|
6673
6786
|
}
|
|
6787
|
+
async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
|
|
6788
|
+
let config;
|
|
6789
|
+
try {
|
|
6790
|
+
config = JSON.parse(await readFile6(configPath, "utf8"));
|
|
6791
|
+
} catch {
|
|
6792
|
+
throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
|
|
6793
|
+
}
|
|
6794
|
+
if (!isRecord8(config) || !isRecord8(config.vars)) {
|
|
6795
|
+
throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
|
|
6796
|
+
}
|
|
6797
|
+
const hosts = endpointHosts(workerName, productionEndpoint);
|
|
6798
|
+
if (!hosts.length) {
|
|
6799
|
+
throw new DeploymentApplyError("MISSING_ENDPOINT", "The Worker production endpoint is invalid");
|
|
6800
|
+
}
|
|
6801
|
+
config.vars.EXPECTED_HOSTS = hosts.join(",");
|
|
6802
|
+
await writeFile7(configPath, `${JSON.stringify(config, null, 2)}
|
|
6803
|
+
`, { mode: 384 });
|
|
6804
|
+
}
|
|
6805
|
+
function endpointHosts(workerName, endpoint) {
|
|
6806
|
+
if (!endpoint) return [];
|
|
6807
|
+
try {
|
|
6808
|
+
const productionHost = new URL(endpoint).host.toLowerCase();
|
|
6809
|
+
const workerPrefix = `${workerName.toLowerCase()}.`;
|
|
6810
|
+
const previewHost = productionHost.startsWith(workerPrefix) ? `moodle-cli-candidate-${productionHost}` : void 0;
|
|
6811
|
+
return [productionHost, ...previewHost ? [previewHost] : []];
|
|
6812
|
+
} catch {
|
|
6813
|
+
return [];
|
|
6814
|
+
}
|
|
6815
|
+
}
|
|
6674
6816
|
function isRetryableSessionUpload(status) {
|
|
6675
6817
|
return status === 401 || isRetryableWorkerPropagation(status);
|
|
6676
6818
|
}
|
|
@@ -6815,6 +6957,8 @@ function isMissing4(error) {
|
|
|
6815
6957
|
}
|
|
6816
6958
|
|
|
6817
6959
|
// src/mcp/gateway.ts
|
|
6960
|
+
import { parse as parse4 } from "node-html-parser";
|
|
6961
|
+
var MAX_MCP_FILE_BYTES = 16 * 1024 * 1024;
|
|
6818
6962
|
var MoodleGatewayError = class extends Error {
|
|
6819
6963
|
code;
|
|
6820
6964
|
constructor(code, message) {
|
|
@@ -6850,9 +6994,212 @@ function createMoodleGateway(client) {
|
|
|
6850
6994
|
return limit === void 0 ? forums : forums.slice(0, limit);
|
|
6851
6995
|
},
|
|
6852
6996
|
searchForums: ({ forumId, ...input }) => client.searchForumContent({ ...input, forumCmid: forumId }),
|
|
6853
|
-
getThread: ({ discussionId }) => client.getForumDiscussion(discussionId)
|
|
6997
|
+
getThread: ({ discussionId }) => client.getForumDiscussion(discussionId),
|
|
6998
|
+
async getFile({ source }) {
|
|
6999
|
+
let target = await resolveFileTarget(client, source);
|
|
7000
|
+
let response = await client.requestAbsolute(target.url);
|
|
7001
|
+
if (isHtml(response)) {
|
|
7002
|
+
const html = await response.text();
|
|
7003
|
+
if (looksLikeLoginPage3(html)) throw fileAuthenticationRequired();
|
|
7004
|
+
const links = resourceLinks2(html, target.url);
|
|
7005
|
+
if (links.length !== 1) {
|
|
7006
|
+
const code = links.length ? "MOODLE_FILE_SOURCE_AMBIGUOUS" : "MOODLE_FILE_NOT_FOUND";
|
|
7007
|
+
throw new MoodleGatewayError(code, "The Moodle resource did not resolve to exactly one file.");
|
|
7008
|
+
}
|
|
7009
|
+
const resolved = await resolveFileTarget(client, links[0].url);
|
|
7010
|
+
target = { ...resolved, name: target.name || links[0].name || resolved.name };
|
|
7011
|
+
response = await client.requestAbsolute(target.url);
|
|
7012
|
+
if (isHtml(response)) {
|
|
7013
|
+
if (looksLikeLoginPage3(await response.text())) throw fileAuthenticationRequired();
|
|
7014
|
+
throw new MoodleGatewayError("MOODLE_FILE_NOT_FOUND", "Moodle did not return a downloadable file.");
|
|
7015
|
+
}
|
|
7016
|
+
}
|
|
7017
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
7018
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_MCP_FILE_BYTES) {
|
|
7019
|
+
throw fileTooLarge();
|
|
7020
|
+
}
|
|
7021
|
+
const content = await readBoundedBody(response, MAX_MCP_FILE_BYTES);
|
|
7022
|
+
const name = safeFilename(contentDispositionFilename2(response.headers.get("content-disposition"))) ?? safeFilename(target.name) ?? safeFilename(urlFilename2(response.url || target.url));
|
|
7023
|
+
if (!name) {
|
|
7024
|
+
throw new MoodleGatewayError("MOODLE_FILE_NAME_MISSING", "Moodle did not provide a safe filename.");
|
|
7025
|
+
}
|
|
7026
|
+
return {
|
|
7027
|
+
name,
|
|
7028
|
+
mimeType: contentType2(response),
|
|
7029
|
+
bytes: content.byteLength,
|
|
7030
|
+
uri: publicFileUrl(response.url || target.url),
|
|
7031
|
+
blob: encodeBase64(content)
|
|
7032
|
+
};
|
|
7033
|
+
}
|
|
6854
7034
|
};
|
|
6855
7035
|
}
|
|
7036
|
+
async function resolveFileTarget(client, rawSource) {
|
|
7037
|
+
const source = String(rawSource).trim();
|
|
7038
|
+
if (/^\d+$/u.test(source)) {
|
|
7039
|
+
const activityId = Number(source);
|
|
7040
|
+
if (!Number.isSafeInteger(activityId) || activityId < 1) throw invalidFileSource();
|
|
7041
|
+
return validateFileTarget(client, await fileFromActivity(client, activityId));
|
|
7042
|
+
}
|
|
7043
|
+
let url;
|
|
7044
|
+
try {
|
|
7045
|
+
url = new URL(source);
|
|
7046
|
+
} catch {
|
|
7047
|
+
throw invalidFileSource();
|
|
7048
|
+
}
|
|
7049
|
+
const site = new URL(client.baseUrl);
|
|
7050
|
+
const sitePath = site.pathname.replace(/\/$/u, "");
|
|
7051
|
+
if (url.origin !== site.origin) throw invalidFileSource();
|
|
7052
|
+
if (url.pathname === `${sitePath}/mod/resource/view.php`) {
|
|
7053
|
+
const activityId = Number(url.searchParams.get("id"));
|
|
7054
|
+
if (!Number.isSafeInteger(activityId) || activityId < 1) throw invalidFileSource();
|
|
7055
|
+
return validateFileTarget(client, await fileFromActivity(client, activityId));
|
|
7056
|
+
}
|
|
7057
|
+
if (!url.pathname.startsWith(`${sitePath}/pluginfile.php/`)) throw invalidFileSource();
|
|
7058
|
+
return { url: url.toString(), name: urlFilename2(url.toString()) };
|
|
7059
|
+
}
|
|
7060
|
+
function validateFileTarget(client, target) {
|
|
7061
|
+
let url;
|
|
7062
|
+
try {
|
|
7063
|
+
url = new URL(target.url);
|
|
7064
|
+
} catch {
|
|
7065
|
+
throw invalidFileSource();
|
|
7066
|
+
}
|
|
7067
|
+
const site = new URL(client.baseUrl);
|
|
7068
|
+
const sitePath = site.pathname.replace(/\/$/u, "");
|
|
7069
|
+
const supportedPath = url.pathname === `${sitePath}/mod/resource/view.php` || url.pathname.startsWith(`${sitePath}/pluginfile.php/`);
|
|
7070
|
+
if (url.origin !== site.origin || !supportedPath) throw invalidFileSource();
|
|
7071
|
+
return target;
|
|
7072
|
+
}
|
|
7073
|
+
async function fileFromActivity(client, activityId) {
|
|
7074
|
+
const activity = await client.getActivity(activityId);
|
|
7075
|
+
if (activity.type !== "resource" || !("file_entries" in activity) || !Array.isArray(activity.file_entries)) {
|
|
7076
|
+
throw new MoodleGatewayError(
|
|
7077
|
+
"MOODLE_FILE_SOURCE_INVALID",
|
|
7078
|
+
`Activity ${activityId} is not a downloadable resource.`
|
|
7079
|
+
);
|
|
7080
|
+
}
|
|
7081
|
+
if (activity.file_entries.length !== 1) {
|
|
7082
|
+
if (activity.file_entries.length === 0) {
|
|
7083
|
+
const resource = activity;
|
|
7084
|
+
const url = resource.target_url || resource.url;
|
|
7085
|
+
if (url) return { url, name: resource.target_name || void 0 };
|
|
7086
|
+
}
|
|
7087
|
+
throw new MoodleGatewayError(
|
|
7088
|
+
"MOODLE_FILE_SOURCE_AMBIGUOUS",
|
|
7089
|
+
`Activity ${activityId} did not resolve to exactly one file. Inspect file_entries and request one URL.`
|
|
7090
|
+
);
|
|
7091
|
+
}
|
|
7092
|
+
const [entry] = activity.file_entries;
|
|
7093
|
+
return { url: entry.url, name: entry.name };
|
|
7094
|
+
}
|
|
7095
|
+
function resourceLinks2(html, baseUrl) {
|
|
7096
|
+
const root = parse4(html);
|
|
7097
|
+
const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((link2) => ({
|
|
7098
|
+
name: link2.textContent.trim(),
|
|
7099
|
+
url: new URL(link2.getAttribute("href") ?? "", baseUrl).toString()
|
|
7100
|
+
})).filter((entry) => entry.url !== baseUrl);
|
|
7101
|
+
return entries.filter((entry, index) => entries.findIndex((candidate) => candidate.url === entry.url) === index);
|
|
7102
|
+
}
|
|
7103
|
+
async function readBoundedBody(response, maxBytes) {
|
|
7104
|
+
if (!response.body) return new Uint8Array();
|
|
7105
|
+
const reader = response.body.getReader();
|
|
7106
|
+
const chunks2 = [];
|
|
7107
|
+
let bytes = 0;
|
|
7108
|
+
while (true) {
|
|
7109
|
+
const { done, value } = await reader.read();
|
|
7110
|
+
if (done) break;
|
|
7111
|
+
bytes += value.byteLength;
|
|
7112
|
+
if (bytes > maxBytes) {
|
|
7113
|
+
await reader.cancel().catch(() => void 0);
|
|
7114
|
+
throw fileTooLarge();
|
|
7115
|
+
}
|
|
7116
|
+
chunks2.push(value);
|
|
7117
|
+
}
|
|
7118
|
+
const content = new Uint8Array(bytes);
|
|
7119
|
+
let offset = 0;
|
|
7120
|
+
for (const chunk of chunks2) {
|
|
7121
|
+
content.set(chunk, offset);
|
|
7122
|
+
offset += chunk.byteLength;
|
|
7123
|
+
}
|
|
7124
|
+
return content;
|
|
7125
|
+
}
|
|
7126
|
+
function invalidFileSource() {
|
|
7127
|
+
return new MoodleGatewayError(
|
|
7128
|
+
"MOODLE_FILE_SOURCE_INVALID",
|
|
7129
|
+
"Use a positive resource activity ID, a same-site resource URL, or a same-site pluginfile URL."
|
|
7130
|
+
);
|
|
7131
|
+
}
|
|
7132
|
+
function fileTooLarge() {
|
|
7133
|
+
return new MoodleGatewayError(
|
|
7134
|
+
"MOODLE_FILE_TOO_LARGE",
|
|
7135
|
+
`Moodle files returned through MCP cannot exceed ${MAX_MCP_FILE_BYTES / 1024 / 1024} MiB.`
|
|
7136
|
+
);
|
|
7137
|
+
}
|
|
7138
|
+
function fileAuthenticationRequired() {
|
|
7139
|
+
return new MoodleGatewayError(
|
|
7140
|
+
"MOODLE_AUTH_REQUIRED",
|
|
7141
|
+
"Moodle returned a login page instead of the requested file."
|
|
7142
|
+
);
|
|
7143
|
+
}
|
|
7144
|
+
function isHtml(response) {
|
|
7145
|
+
if (/\battachment\b/iu.test(response.headers.get("content-disposition") ?? "")) return false;
|
|
7146
|
+
const type = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
7147
|
+
return type.includes("text/html") || type.includes("application/xhtml+xml");
|
|
7148
|
+
}
|
|
7149
|
+
function looksLikeLoginPage3(html) {
|
|
7150
|
+
const root = parse4(html);
|
|
7151
|
+
return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
|
|
7152
|
+
}
|
|
7153
|
+
function contentType2(response) {
|
|
7154
|
+
return response.headers.get("content-type")?.split(";", 1)[0]?.trim() || "application/octet-stream";
|
|
7155
|
+
}
|
|
7156
|
+
function contentDispositionFilename2(value) {
|
|
7157
|
+
if (!value) return void 0;
|
|
7158
|
+
const extended = value.match(/filename\*\s*=\s*([^;]+)/iu)?.[1]?.trim().replace(/^"|"$/gu, "");
|
|
7159
|
+
if (extended) {
|
|
7160
|
+
const encoded = extended.replace(/^[^']*'[^']*'/u, "");
|
|
7161
|
+
try {
|
|
7162
|
+
return decodeURIComponent(encoded);
|
|
7163
|
+
} catch {
|
|
7164
|
+
return encoded;
|
|
7165
|
+
}
|
|
7166
|
+
}
|
|
7167
|
+
const quoted = value.match(/filename\s*=\s*"((?:\\.|[^"])*)"/iu)?.[1];
|
|
7168
|
+
if (quoted !== void 0) return quoted.replace(/\\([\\"])/gu, "$1");
|
|
7169
|
+
return value.match(/filename\s*=\s*([^;]+)/iu)?.[1]?.trim();
|
|
7170
|
+
}
|
|
7171
|
+
function urlFilename2(value) {
|
|
7172
|
+
try {
|
|
7173
|
+
const encoded = new URL(value).pathname.split("/").at(-1) ?? "";
|
|
7174
|
+
try {
|
|
7175
|
+
return decodeURIComponent(encoded);
|
|
7176
|
+
} catch {
|
|
7177
|
+
return encoded;
|
|
7178
|
+
}
|
|
7179
|
+
} catch {
|
|
7180
|
+
return void 0;
|
|
7181
|
+
}
|
|
7182
|
+
}
|
|
7183
|
+
function safeFilename(value) {
|
|
7184
|
+
const name = value?.split(/[\\/]/u).at(-1)?.replace(/[\u0000-\u001f\u007f]/gu, "").trim();
|
|
7185
|
+
return name && name !== "." && name !== ".." ? name : void 0;
|
|
7186
|
+
}
|
|
7187
|
+
function publicFileUrl(value) {
|
|
7188
|
+
const url = new URL(value);
|
|
7189
|
+
url.username = "";
|
|
7190
|
+
url.password = "";
|
|
7191
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
7192
|
+
if (key !== "forcedownload" && key !== "download") url.searchParams.delete(key);
|
|
7193
|
+
}
|
|
7194
|
+
return url.toString();
|
|
7195
|
+
}
|
|
7196
|
+
function encodeBase64(content) {
|
|
7197
|
+
let binary = "";
|
|
7198
|
+
for (let offset = 0; offset < content.byteLength; offset += 32768) {
|
|
7199
|
+
binary += String.fromCharCode(...content.subarray(offset, offset + 32768));
|
|
7200
|
+
}
|
|
7201
|
+
return btoa(binary);
|
|
7202
|
+
}
|
|
6856
7203
|
|
|
6857
7204
|
// src/mcp/server.ts
|
|
6858
7205
|
import { z as z3, ZodError } from "zod";
|
|
@@ -6865,14 +7212,122 @@ var READ_ONLY_ANNOTATIONS = {
|
|
|
6865
7212
|
};
|
|
6866
7213
|
var emptyInput = z3.object({}).strict();
|
|
6867
7214
|
var positiveId = z3.number().int().positive();
|
|
6868
|
-
var
|
|
6869
|
-
var
|
|
7215
|
+
var integer = z3.number().int();
|
|
7216
|
+
var userValue = z3.looseObject({
|
|
7217
|
+
userid: integer,
|
|
7218
|
+
username: z3.string(),
|
|
7219
|
+
fullname: z3.string(),
|
|
7220
|
+
sitename: z3.string(),
|
|
7221
|
+
siteurl: z3.string(),
|
|
7222
|
+
lang: z3.string().optional()
|
|
7223
|
+
});
|
|
7224
|
+
var courseValue = z3.looseObject({
|
|
7225
|
+
id: integer,
|
|
7226
|
+
shortname: z3.string(),
|
|
7227
|
+
fullname: z3.string(),
|
|
7228
|
+
category: z3.number().int(),
|
|
7229
|
+
visible: z3.boolean(),
|
|
7230
|
+
startdate: z3.number(),
|
|
7231
|
+
enddate: z3.number().optional()
|
|
7232
|
+
});
|
|
7233
|
+
var activityValue = z3.looseObject({
|
|
7234
|
+
id: integer,
|
|
7235
|
+
name: z3.string(),
|
|
7236
|
+
modname: z3.string(),
|
|
7237
|
+
url: z3.string(),
|
|
7238
|
+
visible: z3.boolean(),
|
|
7239
|
+
description: z3.string()
|
|
7240
|
+
});
|
|
7241
|
+
var fileEntryValue = z3.looseObject({
|
|
7242
|
+
name: z3.string(),
|
|
7243
|
+
url: z3.string(),
|
|
7244
|
+
requires_authentication: z3.boolean()
|
|
7245
|
+
});
|
|
7246
|
+
var activityDetailValue = z3.looseObject({
|
|
7247
|
+
id: positiveId,
|
|
7248
|
+
name: z3.string(),
|
|
7249
|
+
type: z3.string(),
|
|
7250
|
+
url: z3.string().optional(),
|
|
7251
|
+
target_name: z3.string().optional(),
|
|
7252
|
+
target_url: z3.string().optional(),
|
|
7253
|
+
file_entries: z3.array(fileEntryValue).optional()
|
|
7254
|
+
});
|
|
7255
|
+
var sectionValue = z3.looseObject({
|
|
7256
|
+
id: z3.number().int(),
|
|
7257
|
+
name: z3.string(),
|
|
7258
|
+
section: z3.number().int(),
|
|
7259
|
+
visible: z3.boolean(),
|
|
7260
|
+
summary: z3.string(),
|
|
7261
|
+
activities: z3.array(activityValue)
|
|
7262
|
+
});
|
|
7263
|
+
var todoValue = z3.looseObject({
|
|
7264
|
+
id: z3.number().int(),
|
|
7265
|
+
name: z3.string(),
|
|
7266
|
+
course_id: z3.number().int(),
|
|
7267
|
+
course_name: z3.string(),
|
|
7268
|
+
due_at: z3.number(),
|
|
7269
|
+
url: z3.string()
|
|
7270
|
+
});
|
|
7271
|
+
var gradeItemValue = z3.looseObject({
|
|
7272
|
+
name: z3.string(),
|
|
7273
|
+
item_type: z3.string(),
|
|
7274
|
+
grade: z3.string(),
|
|
7275
|
+
range: z3.string(),
|
|
7276
|
+
percentage: z3.string(),
|
|
7277
|
+
feedback: z3.string(),
|
|
7278
|
+
url: z3.string()
|
|
7279
|
+
});
|
|
7280
|
+
var gradesValue = z3.looseObject({
|
|
7281
|
+
course_id: integer,
|
|
7282
|
+
course_name: z3.string(),
|
|
7283
|
+
learner_name: z3.string(),
|
|
7284
|
+
total_grade: z3.string(),
|
|
7285
|
+
total_range: z3.string(),
|
|
7286
|
+
total_percentage: z3.string(),
|
|
7287
|
+
items: z3.array(gradeItemValue)
|
|
7288
|
+
});
|
|
7289
|
+
var forumValue = z3.looseObject({
|
|
7290
|
+
id: integer,
|
|
7291
|
+
name: z3.string(),
|
|
7292
|
+
course_id: integer,
|
|
7293
|
+
course_name: z3.string(),
|
|
7294
|
+
url: z3.string()
|
|
7295
|
+
});
|
|
7296
|
+
var forumSearchValue = z3.looseObject({
|
|
7297
|
+
course_id: integer,
|
|
7298
|
+
course_name: z3.string(),
|
|
7299
|
+
forum_id: integer,
|
|
7300
|
+
forum_name: z3.string(),
|
|
7301
|
+
discussion_id: integer,
|
|
7302
|
+
discussion_subject: z3.string(),
|
|
7303
|
+
post_id: integer,
|
|
7304
|
+
snippet: z3.string(),
|
|
7305
|
+
url: z3.string()
|
|
7306
|
+
});
|
|
7307
|
+
var forumPostValue = z3.looseObject({
|
|
7308
|
+
id: integer,
|
|
7309
|
+
discussion_id: integer,
|
|
7310
|
+
subject: z3.string(),
|
|
7311
|
+
message_text: z3.string(),
|
|
7312
|
+
author: z3.looseObject({ id: integer, fullname: z3.string() }),
|
|
7313
|
+
url: z3.string()
|
|
7314
|
+
});
|
|
7315
|
+
var threadValue = z3.looseObject({
|
|
7316
|
+
id: integer,
|
|
7317
|
+
subject: z3.string(),
|
|
7318
|
+
course_id: integer,
|
|
7319
|
+
forum_id: integer,
|
|
7320
|
+
group_id: z3.number().int(),
|
|
7321
|
+
group_name: z3.string(),
|
|
7322
|
+
url: z3.string(),
|
|
7323
|
+
posts: z3.array(forumPostValue)
|
|
7324
|
+
});
|
|
6870
7325
|
var TOOL_REGISTRATIONS = [
|
|
6871
7326
|
{
|
|
6872
7327
|
name: "get_user",
|
|
6873
7328
|
description: "Get the authenticated Moodle user and site.",
|
|
6874
7329
|
input: emptyInput,
|
|
6875
|
-
output: z3.object({ user:
|
|
7330
|
+
output: z3.object({ user: userValue })
|
|
6876
7331
|
},
|
|
6877
7332
|
{
|
|
6878
7333
|
name: "get_overview",
|
|
@@ -6882,19 +7337,31 @@ var TOOL_REGISTRATIONS = [
|
|
|
6882
7337
|
todoDays: z3.number().int().min(1).max(365).optional(),
|
|
6883
7338
|
alertsLimit: z3.number().int().min(1).max(100).optional().default(5)
|
|
6884
7339
|
}).strict(),
|
|
6885
|
-
output: z3.object({
|
|
7340
|
+
output: z3.object({
|
|
7341
|
+
overview: z3.looseObject({
|
|
7342
|
+
user: userValue,
|
|
7343
|
+
courses: z3.array(courseValue),
|
|
7344
|
+
todo: z3.array(todoValue),
|
|
7345
|
+
errors: z3.array(z3.string())
|
|
7346
|
+
})
|
|
7347
|
+
})
|
|
6886
7348
|
},
|
|
6887
7349
|
{
|
|
6888
7350
|
name: "list_courses",
|
|
6889
7351
|
description: "List the authenticated user's Moodle courses.",
|
|
6890
7352
|
input: z3.object({ limit: z3.number().int().min(1).max(200).optional().default(100) }).strict(),
|
|
6891
|
-
output: z3.object({ courses:
|
|
7353
|
+
output: z3.object({ courses: z3.array(courseValue) })
|
|
6892
7354
|
},
|
|
6893
7355
|
{
|
|
6894
7356
|
name: "get_course",
|
|
6895
7357
|
description: "Get one Moodle course and its sections.",
|
|
6896
7358
|
input: z3.object({ courseId: positiveId }).strict(),
|
|
6897
|
-
output: z3.object({
|
|
7359
|
+
output: z3.object({
|
|
7360
|
+
course: z3.looseObject({
|
|
7361
|
+
course: courseValue,
|
|
7362
|
+
sections: z3.array(sectionValue)
|
|
7363
|
+
})
|
|
7364
|
+
})
|
|
6898
7365
|
},
|
|
6899
7366
|
{
|
|
6900
7367
|
name: "list_activities",
|
|
@@ -6903,19 +7370,19 @@ var TOOL_REGISTRATIONS = [
|
|
|
6903
7370
|
courseId: positiveId,
|
|
6904
7371
|
limit: z3.number().int().min(1).max(200).optional().default(100)
|
|
6905
7372
|
}).strict(),
|
|
6906
|
-
output: z3.object({ activities:
|
|
7373
|
+
output: z3.object({ activities: z3.array(activityValue) })
|
|
6907
7374
|
},
|
|
6908
7375
|
{
|
|
6909
7376
|
name: "get_activity",
|
|
6910
7377
|
description: "Get the supported details for one Moodle activity.",
|
|
6911
7378
|
input: z3.object({ activityId: positiveId }).strict(),
|
|
6912
|
-
output: z3.object({ activity:
|
|
7379
|
+
output: z3.object({ activity: activityDetailValue })
|
|
6913
7380
|
},
|
|
6914
7381
|
{
|
|
6915
7382
|
name: "get_grades",
|
|
6916
7383
|
description: "Get the authenticated user's grades for one Moodle course.",
|
|
6917
7384
|
input: z3.object({ courseId: positiveId }).strict(),
|
|
6918
|
-
output: z3.object({ grades:
|
|
7385
|
+
output: z3.object({ grades: gradesValue })
|
|
6919
7386
|
},
|
|
6920
7387
|
{
|
|
6921
7388
|
name: "list_forums",
|
|
@@ -6924,7 +7391,7 @@ var TOOL_REGISTRATIONS = [
|
|
|
6924
7391
|
courseId: positiveId.optional(),
|
|
6925
7392
|
limit: z3.number().int().min(1).max(100).optional().default(50)
|
|
6926
7393
|
}).strict(),
|
|
6927
|
-
output: z3.object({ forums:
|
|
7394
|
+
output: z3.object({ forums: z3.array(forumValue) })
|
|
6928
7395
|
},
|
|
6929
7396
|
{
|
|
6930
7397
|
name: "search_forums",
|
|
@@ -6940,13 +7407,28 @@ var TOOL_REGISTRATIONS = [
|
|
|
6940
7407
|
maxForums: z3.number().int().min(1).max(50).optional(),
|
|
6941
7408
|
maxDiscussionsPerForum: z3.number().int().min(1).max(100).optional()
|
|
6942
7409
|
}).strict(),
|
|
6943
|
-
output: z3.object({ results:
|
|
7410
|
+
output: z3.object({ results: z3.array(forumSearchValue) })
|
|
6944
7411
|
},
|
|
6945
7412
|
{
|
|
6946
7413
|
name: "get_thread",
|
|
6947
7414
|
description: "Get one Moodle forum discussion and its posts.",
|
|
6948
7415
|
input: z3.object({ discussionId: positiveId }).strict(),
|
|
6949
|
-
output: z3.object({ thread:
|
|
7416
|
+
output: z3.object({ thread: threadValue })
|
|
7417
|
+
},
|
|
7418
|
+
{
|
|
7419
|
+
name: "get_file",
|
|
7420
|
+
description: "Fetch one authenticated Moodle file and return its content directly (maximum 16 MiB).",
|
|
7421
|
+
input: z3.object({
|
|
7422
|
+
source: z3.union([positiveId, z3.string().trim().min(1).max(2048)])
|
|
7423
|
+
}).strict(),
|
|
7424
|
+
output: z3.object({
|
|
7425
|
+
file: z3.object({
|
|
7426
|
+
name: z3.string(),
|
|
7427
|
+
mime_type: z3.string(),
|
|
7428
|
+
bytes: z3.number().int().nonnegative(),
|
|
7429
|
+
uri: z3.string()
|
|
7430
|
+
})
|
|
7431
|
+
})
|
|
6950
7432
|
}
|
|
6951
7433
|
];
|
|
6952
7434
|
var TOOL_CATALOG = TOOL_REGISTRATIONS.map(({ name, description, input, output }) => ({
|
|
@@ -7014,12 +7496,11 @@ function createMoodleMcpServer(gateway, options = {}) {
|
|
|
7014
7496
|
}
|
|
7015
7497
|
if (error instanceof UnsupportedProtocolVersionError) {
|
|
7016
7498
|
return jsonRpcFailure(id, {
|
|
7017
|
-
code: -
|
|
7018
|
-
message:
|
|
7499
|
+
code: -32022,
|
|
7500
|
+
message: "Unsupported protocol version",
|
|
7019
7501
|
data: {
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
supportedVersions: [...error.supportedVersions]
|
|
7502
|
+
supported: [...error.supportedVersions],
|
|
7503
|
+
requested: error.protocolVersion
|
|
7023
7504
|
}
|
|
7024
7505
|
});
|
|
7025
7506
|
}
|
|
@@ -7067,7 +7548,7 @@ async function callTool(gateway, params) {
|
|
|
7067
7548
|
const payload = await runGatewayTool(gateway, name, input);
|
|
7068
7549
|
const structuredContent = registration.output.parse(wrapToolOutput(name, payload));
|
|
7069
7550
|
return {
|
|
7070
|
-
content:
|
|
7551
|
+
content: toolContent(name, payload),
|
|
7071
7552
|
structuredContent,
|
|
7072
7553
|
resultType: "complete",
|
|
7073
7554
|
_meta: RESULT_META
|
|
@@ -7128,6 +7609,8 @@ async function runGatewayTool(gateway, name, input) {
|
|
|
7128
7609
|
});
|
|
7129
7610
|
case "get_thread":
|
|
7130
7611
|
return gateway.getThread({ discussionId: numberValue3(input.discussionId) });
|
|
7612
|
+
case "get_file":
|
|
7613
|
+
return gateway.getFile({ source: fileSource(input.source) });
|
|
7131
7614
|
default:
|
|
7132
7615
|
throw new McpCallError("TOOL_NOT_FOUND", `Unknown Moodle tool: ${name}`);
|
|
7133
7616
|
}
|
|
@@ -7145,8 +7628,33 @@ function wrapToolOutput(name, payload) {
|
|
|
7145
7628
|
search_forums: "results",
|
|
7146
7629
|
get_thread: "thread"
|
|
7147
7630
|
};
|
|
7631
|
+
if (name === "get_file" && isMoodleFile(payload)) {
|
|
7632
|
+
return {
|
|
7633
|
+
file: {
|
|
7634
|
+
name: payload.name,
|
|
7635
|
+
mime_type: payload.mimeType,
|
|
7636
|
+
bytes: payload.bytes,
|
|
7637
|
+
uri: payload.uri
|
|
7638
|
+
}
|
|
7639
|
+
};
|
|
7640
|
+
}
|
|
7148
7641
|
return { [keys[name] ?? "result"]: payload };
|
|
7149
7642
|
}
|
|
7643
|
+
function toolContent(name, payload) {
|
|
7644
|
+
const text = { type: "text", text: summarizeToolOutput(name, payload) };
|
|
7645
|
+
if (name !== "get_file" || !isMoodleFile(payload)) return [text];
|
|
7646
|
+
return [
|
|
7647
|
+
text,
|
|
7648
|
+
{
|
|
7649
|
+
type: "resource",
|
|
7650
|
+
resource: {
|
|
7651
|
+
uri: payload.uri,
|
|
7652
|
+
mimeType: payload.mimeType,
|
|
7653
|
+
blob: payload.blob
|
|
7654
|
+
}
|
|
7655
|
+
}
|
|
7656
|
+
];
|
|
7657
|
+
}
|
|
7150
7658
|
function summarizeToolOutput(name, payload) {
|
|
7151
7659
|
if (Array.isArray(payload)) {
|
|
7152
7660
|
const labels = {
|
|
@@ -7178,6 +7686,9 @@ function summarizeToolOutput(name, payload) {
|
|
|
7178
7686
|
if (name === "get_thread" && isRecord9(payload)) {
|
|
7179
7687
|
return `Loaded forum thread ${stringValue4(payload.subject) || numberValue3(payload.id)}.`;
|
|
7180
7688
|
}
|
|
7689
|
+
if (name === "get_file" && isMoodleFile(payload)) {
|
|
7690
|
+
return `Loaded Moodle file ${payload.name} (${payload.bytes} bytes).`;
|
|
7691
|
+
}
|
|
7181
7692
|
return "Moodle request completed.";
|
|
7182
7693
|
}
|
|
7183
7694
|
function mapMoodleError(error) {
|
|
@@ -7213,6 +7724,9 @@ function optionalNumber(value) {
|
|
|
7213
7724
|
function stringValue4(value) {
|
|
7214
7725
|
return typeof value === "string" ? value : "";
|
|
7215
7726
|
}
|
|
7727
|
+
function fileSource(value) {
|
|
7728
|
+
return typeof value === "number" || typeof value === "string" ? value : "";
|
|
7729
|
+
}
|
|
7216
7730
|
function booleanValue2(value) {
|
|
7217
7731
|
return value === true;
|
|
7218
7732
|
}
|
|
@@ -7222,6 +7736,9 @@ function enumValue(value, values) {
|
|
|
7222
7736
|
function isRecord9(value) {
|
|
7223
7737
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7224
7738
|
}
|
|
7739
|
+
function isMoodleFile(value) {
|
|
7740
|
+
return isRecord9(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
|
|
7741
|
+
}
|
|
7225
7742
|
|
|
7226
7743
|
// src/mcp/stdio.ts
|
|
7227
7744
|
async function serveMoodleMcpStdio(server, options) {
|
|
@@ -7309,8 +7826,7 @@ var DefaultMcpCommandService = class {
|
|
|
7309
7826
|
this.worker = options.worker ?? new FetchManagedWorkerClient(options.fetchImpl);
|
|
7310
7827
|
this.renewal = options.renewal ?? new DefaultRenewalIntegration({
|
|
7311
7828
|
platform: process.platform,
|
|
7312
|
-
homeDirectory: this.homeDirectory
|
|
7313
|
-
executable: process.argv[1] ?? process.execPath
|
|
7829
|
+
homeDirectory: this.homeDirectory
|
|
7314
7830
|
});
|
|
7315
7831
|
this.sessions = options.sessions ?? createBackgroundMoodleSessionSource({
|
|
7316
7832
|
env: options.env,
|
|
@@ -7473,7 +7989,6 @@ var DefaultMcpCommandService = class {
|
|
|
7473
7989
|
const connectors = createDefaultClientConnectors(profile, {
|
|
7474
7990
|
homeDirectory: this.homeDirectory,
|
|
7475
7991
|
platform: process.platform,
|
|
7476
|
-
command: process.argv[1] ?? "moodle",
|
|
7477
7992
|
mode: input.mode,
|
|
7478
7993
|
...input.mode === "remote" ? { endpoint, accessToken: credentials.mcpAccessToken } : {}
|
|
7479
7994
|
});
|
|
@@ -7676,7 +8191,6 @@ var DefaultMcpCommandService = class {
|
|
|
7676
8191
|
compatibilityDate: this.options.compatibilityDate ?? WORKER_COMPATIBILITY_DATE,
|
|
7677
8192
|
homeDirectory: this.homeDirectory,
|
|
7678
8193
|
platform: process.platform,
|
|
7679
|
-
executable: process.argv[1] ?? process.execPath,
|
|
7680
8194
|
fetch: this.options.fetchImpl,
|
|
7681
8195
|
auth: {
|
|
7682
8196
|
env: this.options.env,
|
|
@@ -7748,8 +8262,7 @@ Selection: `)).trim());
|
|
|
7748
8262
|
async connectedClientNames(profile) {
|
|
7749
8263
|
const connectors = createDefaultClientConnectors(profile, {
|
|
7750
8264
|
homeDirectory: this.homeDirectory,
|
|
7751
|
-
platform: process.platform
|
|
7752
|
-
command: process.argv[1] ?? "moodle"
|
|
8265
|
+
platform: process.platform
|
|
7753
8266
|
});
|
|
7754
8267
|
const clients = [];
|
|
7755
8268
|
for (const connector of connectors) {
|