moodle-cli 0.7.0-alpha.4 → 0.7.0-alpha.5
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 +469 -34
- package/dist/worker/worker.js +1255 -839
- package/package.json +1 -1
- package/references/downloads.md +5 -1
package/dist/moodle.js
CHANGED
|
@@ -1010,9 +1010,9 @@ function parseCourseSectionNumbers(html, courseId) {
|
|
|
1010
1010
|
for (const href of hrefs) {
|
|
1011
1011
|
const url = parseMaybeUrl(href.replace(/&/gu, "&"), "https://moodle.invalid");
|
|
1012
1012
|
const id = url?.searchParams.get("id");
|
|
1013
|
-
const
|
|
1014
|
-
if (id === String(courseId) &&
|
|
1015
|
-
const section = Number(
|
|
1013
|
+
const sectionValue2 = url?.searchParams.get("section");
|
|
1014
|
+
if (id === String(courseId) && sectionValue2 && /^\d+$/.test(sectionValue2)) {
|
|
1015
|
+
const section = Number(sectionValue2);
|
|
1016
1016
|
if (!sections.includes(section)) {
|
|
1017
1017
|
sections.push(section);
|
|
1018
1018
|
}
|
|
@@ -2286,8 +2286,8 @@ var MoodleClientCore = class {
|
|
|
2286
2286
|
}
|
|
2287
2287
|
if (!response.ok) {
|
|
2288
2288
|
const context = `HTTP ${response.status} loading ${safeUrl(url)}`;
|
|
2289
|
-
const
|
|
2290
|
-
if (
|
|
2289
|
+
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
2290
|
+
if (contentType3.includes("text/html") || contentType3.includes("application/xhtml+xml")) {
|
|
2291
2291
|
const moodleError = await response.text().then(parseMoodleErrorHtml).catch(() => null);
|
|
2292
2292
|
if (moodleError) {
|
|
2293
2293
|
throw this.errors.api(`${moodleError.message} (${context})`, moodleError.code);
|
|
@@ -2669,8 +2669,8 @@ async function probeBaseUrl(baseUrl, options = {}) {
|
|
|
2669
2669
|
return { ok: false, message: `Could not reach ${baseUrl}: ${error instanceof Error ? error.message : String(error)}` };
|
|
2670
2670
|
}
|
|
2671
2671
|
const body = (await response.text()).slice(0, 5e3).toLowerCase();
|
|
2672
|
-
const
|
|
2673
|
-
const looksJson =
|
|
2672
|
+
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
2673
|
+
const looksJson = contentType3.includes("application/json") || body.startsWith("{");
|
|
2674
2674
|
const looksMoodleTokenError = [
|
|
2675
2675
|
'"errorcode":"missingparam"',
|
|
2676
2676
|
'"errorcode":"invalidparameter"',
|
|
@@ -3827,7 +3827,7 @@ function escapeXml(value) {
|
|
|
3827
3827
|
}
|
|
3828
3828
|
|
|
3829
3829
|
// src/version.ts
|
|
3830
|
-
var VERSION = "0.7.0-alpha.
|
|
3830
|
+
var VERSION = "0.7.0-alpha.5";
|
|
3831
3831
|
|
|
3832
3832
|
// src/forum.ts
|
|
3833
3833
|
function parseDiscussionReference(value) {
|
|
@@ -3861,11 +3861,11 @@ async function parseForumReference(value, resolveForumCmid) {
|
|
|
3861
3861
|
throw new UsageError("FORUM must be a numeric ID or a full forum URL.");
|
|
3862
3862
|
}
|
|
3863
3863
|
if (url.pathname.endsWith("/mod/forum/view.php")) {
|
|
3864
|
-
const
|
|
3865
|
-
if (!
|
|
3864
|
+
const forumValue2 = url.searchParams.get("id");
|
|
3865
|
+
if (!forumValue2 || !/^\d+$/.test(forumValue2)) {
|
|
3866
3866
|
throw new UsageError("Could not find forum module ID in view.php URL (expected ?id=...).");
|
|
3867
3867
|
}
|
|
3868
|
-
return Number(
|
|
3868
|
+
return Number(forumValue2);
|
|
3869
3869
|
}
|
|
3870
3870
|
if (url.pathname.endsWith("/mod/forum/discuss.php")) {
|
|
3871
3871
|
const discussionValue = url.searchParams.get("d");
|
|
@@ -4125,11 +4125,12 @@ async function bridgeRemoteMcp(options) {
|
|
|
4125
4125
|
}
|
|
4126
4126
|
if (response.status === 202 || request.id === void 0) return;
|
|
4127
4127
|
if (!response.ok) {
|
|
4128
|
+
if (await forwardProtocolNegotiationError(options.output, response, request.id)) return;
|
|
4128
4129
|
await writeRemoteError(options.output, request.id, response.status);
|
|
4129
4130
|
return;
|
|
4130
4131
|
}
|
|
4131
|
-
const
|
|
4132
|
-
if (
|
|
4132
|
+
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
4133
|
+
if (contentType3.includes("text/event-stream")) {
|
|
4133
4134
|
const forwarded = await writeSseMessages(options.output, await response.text(), request.id);
|
|
4134
4135
|
if (forwarded) negotiatedProtocolVersion = initializedProtocolVersion(request) ?? negotiatedProtocolVersion;
|
|
4135
4136
|
return;
|
|
@@ -4204,6 +4205,30 @@ async function writeRemoteError(output, id, status) {
|
|
|
4204
4205
|
data: { type: "REMOTE_MCP_ERROR", status }
|
|
4205
4206
|
}));
|
|
4206
4207
|
}
|
|
4208
|
+
async function forwardProtocolNegotiationError(output, response, id) {
|
|
4209
|
+
if (response.status !== 400 || !response.headers.get("content-type")?.toLowerCase().includes("application/json")) {
|
|
4210
|
+
return false;
|
|
4211
|
+
}
|
|
4212
|
+
try {
|
|
4213
|
+
const payload = await response.json();
|
|
4214
|
+
if (!isRecord7(payload) || payload.jsonrpc !== "2.0" || payload.id !== id || !isRecord7(payload.error)) {
|
|
4215
|
+
return false;
|
|
4216
|
+
}
|
|
4217
|
+
const data = isRecord7(payload.error.data) ? payload.error.data : void 0;
|
|
4218
|
+
const supported = Array.isArray(data?.supported) ? data.supported.filter((version) => typeof version === "string") : [];
|
|
4219
|
+
if (payload.error.code !== -32022 || typeof data?.requested !== "string" || supported.length === 0) {
|
|
4220
|
+
return false;
|
|
4221
|
+
}
|
|
4222
|
+
await writeJson(output, jsonRpcFailure(id, {
|
|
4223
|
+
code: -32022,
|
|
4224
|
+
message: "Unsupported protocol version",
|
|
4225
|
+
data: { supported, requested: data.requested }
|
|
4226
|
+
}));
|
|
4227
|
+
return true;
|
|
4228
|
+
} catch {
|
|
4229
|
+
return false;
|
|
4230
|
+
}
|
|
4231
|
+
}
|
|
4207
4232
|
async function writeJson(output, value) {
|
|
4208
4233
|
await output.write(`${JSON.stringify(value)}
|
|
4209
4234
|
`);
|
|
@@ -6314,7 +6339,9 @@ ${error.stderr}`)) {
|
|
|
6314
6339
|
throw new DeploymentApplyError("INITIAL_WORKER_INVALID", "Wrangler did not return the initialized Worker");
|
|
6315
6340
|
}
|
|
6316
6341
|
const productionEndpoint = firstWorkersDevUrl([result.stdout, result.stderr]);
|
|
6317
|
-
|
|
6342
|
+
const initialized = productionEndpoint ? { ...worker, productionEndpoint } : worker;
|
|
6343
|
+
await pinExpectedHosts(input.configPath, input.workerName, initialized.productionEndpoint);
|
|
6344
|
+
return initialized;
|
|
6318
6345
|
} catch (error) {
|
|
6319
6346
|
const worker = await this.inspect(input.accountId, input.workerName).catch(() => null);
|
|
6320
6347
|
if (worker || result) {
|
|
@@ -6412,6 +6439,7 @@ var NodeReleaseMaterializer = class {
|
|
|
6412
6439
|
await copyFile(this.options.workerBundlePath, workerFile);
|
|
6413
6440
|
const wranglerConfigPath = join7(artifactDirectory, "wrangler.json");
|
|
6414
6441
|
const secretsFilePath = join7(artifactDirectory, "secrets.json");
|
|
6442
|
+
const expectedHosts = endpointHosts(plan.intent.workerName, plan.existing?.productionEndpoint);
|
|
6415
6443
|
const config = {
|
|
6416
6444
|
$schema: "node_modules/wrangler/config-schema.json",
|
|
6417
6445
|
name: plan.intent.workerName,
|
|
@@ -6419,7 +6447,10 @@ var NodeReleaseMaterializer = class {
|
|
|
6419
6447
|
main: `./${basename(workerFile)}`,
|
|
6420
6448
|
compatibility_date: this.options.compatibilityDate,
|
|
6421
6449
|
preview_urls: true,
|
|
6422
|
-
vars: {
|
|
6450
|
+
vars: {
|
|
6451
|
+
MOODLE_ORIGIN: plan.intent.moodleOrigin,
|
|
6452
|
+
...expectedHosts.length ? { EXPECTED_HOSTS: expectedHosts.join(",") } : {}
|
|
6453
|
+
},
|
|
6423
6454
|
durable_objects: {
|
|
6424
6455
|
bindings: [{ name: "SESSION_BROKER", class_name: "SessionBroker" }]
|
|
6425
6456
|
},
|
|
@@ -6671,6 +6702,35 @@ function ownershipId(accountId, workerName) {
|
|
|
6671
6702
|
function endpointUrl(endpoint, path4) {
|
|
6672
6703
|
return `${endpoint.replace(/\/$/u, "")}${path4}`;
|
|
6673
6704
|
}
|
|
6705
|
+
async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
|
|
6706
|
+
let config;
|
|
6707
|
+
try {
|
|
6708
|
+
config = JSON.parse(await readFile6(configPath, "utf8"));
|
|
6709
|
+
} catch {
|
|
6710
|
+
throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
|
|
6711
|
+
}
|
|
6712
|
+
if (!isRecord8(config) || !isRecord8(config.vars)) {
|
|
6713
|
+
throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
|
|
6714
|
+
}
|
|
6715
|
+
const hosts = endpointHosts(workerName, productionEndpoint);
|
|
6716
|
+
if (!hosts.length) {
|
|
6717
|
+
throw new DeploymentApplyError("MISSING_ENDPOINT", "The Worker production endpoint is invalid");
|
|
6718
|
+
}
|
|
6719
|
+
config.vars.EXPECTED_HOSTS = hosts.join(",");
|
|
6720
|
+
await writeFile7(configPath, `${JSON.stringify(config, null, 2)}
|
|
6721
|
+
`, { mode: 384 });
|
|
6722
|
+
}
|
|
6723
|
+
function endpointHosts(workerName, endpoint) {
|
|
6724
|
+
if (!endpoint) return [];
|
|
6725
|
+
try {
|
|
6726
|
+
const productionHost = new URL(endpoint).host.toLowerCase();
|
|
6727
|
+
const workerPrefix = `${workerName.toLowerCase()}.`;
|
|
6728
|
+
const previewHost = productionHost.startsWith(workerPrefix) ? `moodle-cli-candidate-${productionHost}` : void 0;
|
|
6729
|
+
return [productionHost, ...previewHost ? [previewHost] : []];
|
|
6730
|
+
} catch {
|
|
6731
|
+
return [];
|
|
6732
|
+
}
|
|
6733
|
+
}
|
|
6674
6734
|
function isRetryableSessionUpload(status) {
|
|
6675
6735
|
return status === 401 || isRetryableWorkerPropagation(status);
|
|
6676
6736
|
}
|
|
@@ -6815,6 +6875,8 @@ function isMissing4(error) {
|
|
|
6815
6875
|
}
|
|
6816
6876
|
|
|
6817
6877
|
// src/mcp/gateway.ts
|
|
6878
|
+
import { parse as parse4 } from "node-html-parser";
|
|
6879
|
+
var MAX_MCP_FILE_BYTES = 16 * 1024 * 1024;
|
|
6818
6880
|
var MoodleGatewayError = class extends Error {
|
|
6819
6881
|
code;
|
|
6820
6882
|
constructor(code, message) {
|
|
@@ -6850,9 +6912,212 @@ function createMoodleGateway(client) {
|
|
|
6850
6912
|
return limit === void 0 ? forums : forums.slice(0, limit);
|
|
6851
6913
|
},
|
|
6852
6914
|
searchForums: ({ forumId, ...input }) => client.searchForumContent({ ...input, forumCmid: forumId }),
|
|
6853
|
-
getThread: ({ discussionId }) => client.getForumDiscussion(discussionId)
|
|
6915
|
+
getThread: ({ discussionId }) => client.getForumDiscussion(discussionId),
|
|
6916
|
+
async getFile({ source }) {
|
|
6917
|
+
let target = await resolveFileTarget(client, source);
|
|
6918
|
+
let response = await client.requestAbsolute(target.url);
|
|
6919
|
+
if (isHtml(response)) {
|
|
6920
|
+
const html = await response.text();
|
|
6921
|
+
if (looksLikeLoginPage3(html)) throw fileAuthenticationRequired();
|
|
6922
|
+
const links = resourceLinks2(html, target.url);
|
|
6923
|
+
if (links.length !== 1) {
|
|
6924
|
+
const code = links.length ? "MOODLE_FILE_SOURCE_AMBIGUOUS" : "MOODLE_FILE_NOT_FOUND";
|
|
6925
|
+
throw new MoodleGatewayError(code, "The Moodle resource did not resolve to exactly one file.");
|
|
6926
|
+
}
|
|
6927
|
+
const resolved = await resolveFileTarget(client, links[0].url);
|
|
6928
|
+
target = { ...resolved, name: target.name || links[0].name || resolved.name };
|
|
6929
|
+
response = await client.requestAbsolute(target.url);
|
|
6930
|
+
if (isHtml(response)) {
|
|
6931
|
+
if (looksLikeLoginPage3(await response.text())) throw fileAuthenticationRequired();
|
|
6932
|
+
throw new MoodleGatewayError("MOODLE_FILE_NOT_FOUND", "Moodle did not return a downloadable file.");
|
|
6933
|
+
}
|
|
6934
|
+
}
|
|
6935
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
6936
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_MCP_FILE_BYTES) {
|
|
6937
|
+
throw fileTooLarge();
|
|
6938
|
+
}
|
|
6939
|
+
const content = await readBoundedBody(response, MAX_MCP_FILE_BYTES);
|
|
6940
|
+
const name = safeFilename(contentDispositionFilename2(response.headers.get("content-disposition"))) ?? safeFilename(target.name) ?? safeFilename(urlFilename2(response.url || target.url));
|
|
6941
|
+
if (!name) {
|
|
6942
|
+
throw new MoodleGatewayError("MOODLE_FILE_NAME_MISSING", "Moodle did not provide a safe filename.");
|
|
6943
|
+
}
|
|
6944
|
+
return {
|
|
6945
|
+
name,
|
|
6946
|
+
mimeType: contentType2(response),
|
|
6947
|
+
bytes: content.byteLength,
|
|
6948
|
+
uri: publicFileUrl(response.url || target.url),
|
|
6949
|
+
blob: encodeBase64(content)
|
|
6950
|
+
};
|
|
6951
|
+
}
|
|
6854
6952
|
};
|
|
6855
6953
|
}
|
|
6954
|
+
async function resolveFileTarget(client, rawSource) {
|
|
6955
|
+
const source = String(rawSource).trim();
|
|
6956
|
+
if (/^\d+$/u.test(source)) {
|
|
6957
|
+
const activityId = Number(source);
|
|
6958
|
+
if (!Number.isSafeInteger(activityId) || activityId < 1) throw invalidFileSource();
|
|
6959
|
+
return validateFileTarget(client, await fileFromActivity(client, activityId));
|
|
6960
|
+
}
|
|
6961
|
+
let url;
|
|
6962
|
+
try {
|
|
6963
|
+
url = new URL(source);
|
|
6964
|
+
} catch {
|
|
6965
|
+
throw invalidFileSource();
|
|
6966
|
+
}
|
|
6967
|
+
const site = new URL(client.baseUrl);
|
|
6968
|
+
const sitePath = site.pathname.replace(/\/$/u, "");
|
|
6969
|
+
if (url.origin !== site.origin) throw invalidFileSource();
|
|
6970
|
+
if (url.pathname === `${sitePath}/mod/resource/view.php`) {
|
|
6971
|
+
const activityId = Number(url.searchParams.get("id"));
|
|
6972
|
+
if (!Number.isSafeInteger(activityId) || activityId < 1) throw invalidFileSource();
|
|
6973
|
+
return validateFileTarget(client, await fileFromActivity(client, activityId));
|
|
6974
|
+
}
|
|
6975
|
+
if (!url.pathname.startsWith(`${sitePath}/pluginfile.php/`)) throw invalidFileSource();
|
|
6976
|
+
return { url: url.toString(), name: urlFilename2(url.toString()) };
|
|
6977
|
+
}
|
|
6978
|
+
function validateFileTarget(client, target) {
|
|
6979
|
+
let url;
|
|
6980
|
+
try {
|
|
6981
|
+
url = new URL(target.url);
|
|
6982
|
+
} catch {
|
|
6983
|
+
throw invalidFileSource();
|
|
6984
|
+
}
|
|
6985
|
+
const site = new URL(client.baseUrl);
|
|
6986
|
+
const sitePath = site.pathname.replace(/\/$/u, "");
|
|
6987
|
+
const supportedPath = url.pathname === `${sitePath}/mod/resource/view.php` || url.pathname.startsWith(`${sitePath}/pluginfile.php/`);
|
|
6988
|
+
if (url.origin !== site.origin || !supportedPath) throw invalidFileSource();
|
|
6989
|
+
return target;
|
|
6990
|
+
}
|
|
6991
|
+
async function fileFromActivity(client, activityId) {
|
|
6992
|
+
const activity = await client.getActivity(activityId);
|
|
6993
|
+
if (activity.type !== "resource" || !("file_entries" in activity) || !Array.isArray(activity.file_entries)) {
|
|
6994
|
+
throw new MoodleGatewayError(
|
|
6995
|
+
"MOODLE_FILE_SOURCE_INVALID",
|
|
6996
|
+
`Activity ${activityId} is not a downloadable resource.`
|
|
6997
|
+
);
|
|
6998
|
+
}
|
|
6999
|
+
if (activity.file_entries.length !== 1) {
|
|
7000
|
+
if (activity.file_entries.length === 0) {
|
|
7001
|
+
const resource = activity;
|
|
7002
|
+
const url = resource.target_url || resource.url;
|
|
7003
|
+
if (url) return { url, name: resource.target_name || void 0 };
|
|
7004
|
+
}
|
|
7005
|
+
throw new MoodleGatewayError(
|
|
7006
|
+
"MOODLE_FILE_SOURCE_AMBIGUOUS",
|
|
7007
|
+
`Activity ${activityId} did not resolve to exactly one file. Inspect file_entries and request one URL.`
|
|
7008
|
+
);
|
|
7009
|
+
}
|
|
7010
|
+
const [entry] = activity.file_entries;
|
|
7011
|
+
return { url: entry.url, name: entry.name };
|
|
7012
|
+
}
|
|
7013
|
+
function resourceLinks2(html, baseUrl) {
|
|
7014
|
+
const root = parse4(html);
|
|
7015
|
+
const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((link2) => ({
|
|
7016
|
+
name: link2.textContent.trim(),
|
|
7017
|
+
url: new URL(link2.getAttribute("href") ?? "", baseUrl).toString()
|
|
7018
|
+
})).filter((entry) => entry.url !== baseUrl);
|
|
7019
|
+
return entries.filter((entry, index) => entries.findIndex((candidate) => candidate.url === entry.url) === index);
|
|
7020
|
+
}
|
|
7021
|
+
async function readBoundedBody(response, maxBytes) {
|
|
7022
|
+
if (!response.body) return new Uint8Array();
|
|
7023
|
+
const reader = response.body.getReader();
|
|
7024
|
+
const chunks2 = [];
|
|
7025
|
+
let bytes = 0;
|
|
7026
|
+
while (true) {
|
|
7027
|
+
const { done, value } = await reader.read();
|
|
7028
|
+
if (done) break;
|
|
7029
|
+
bytes += value.byteLength;
|
|
7030
|
+
if (bytes > maxBytes) {
|
|
7031
|
+
await reader.cancel().catch(() => void 0);
|
|
7032
|
+
throw fileTooLarge();
|
|
7033
|
+
}
|
|
7034
|
+
chunks2.push(value);
|
|
7035
|
+
}
|
|
7036
|
+
const content = new Uint8Array(bytes);
|
|
7037
|
+
let offset = 0;
|
|
7038
|
+
for (const chunk of chunks2) {
|
|
7039
|
+
content.set(chunk, offset);
|
|
7040
|
+
offset += chunk.byteLength;
|
|
7041
|
+
}
|
|
7042
|
+
return content;
|
|
7043
|
+
}
|
|
7044
|
+
function invalidFileSource() {
|
|
7045
|
+
return new MoodleGatewayError(
|
|
7046
|
+
"MOODLE_FILE_SOURCE_INVALID",
|
|
7047
|
+
"Use a positive resource activity ID, a same-site resource URL, or a same-site pluginfile URL."
|
|
7048
|
+
);
|
|
7049
|
+
}
|
|
7050
|
+
function fileTooLarge() {
|
|
7051
|
+
return new MoodleGatewayError(
|
|
7052
|
+
"MOODLE_FILE_TOO_LARGE",
|
|
7053
|
+
`Moodle files returned through MCP cannot exceed ${MAX_MCP_FILE_BYTES / 1024 / 1024} MiB.`
|
|
7054
|
+
);
|
|
7055
|
+
}
|
|
7056
|
+
function fileAuthenticationRequired() {
|
|
7057
|
+
return new MoodleGatewayError(
|
|
7058
|
+
"MOODLE_AUTH_REQUIRED",
|
|
7059
|
+
"Moodle returned a login page instead of the requested file."
|
|
7060
|
+
);
|
|
7061
|
+
}
|
|
7062
|
+
function isHtml(response) {
|
|
7063
|
+
if (/\battachment\b/iu.test(response.headers.get("content-disposition") ?? "")) return false;
|
|
7064
|
+
const type = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
7065
|
+
return type.includes("text/html") || type.includes("application/xhtml+xml");
|
|
7066
|
+
}
|
|
7067
|
+
function looksLikeLoginPage3(html) {
|
|
7068
|
+
const root = parse4(html);
|
|
7069
|
+
return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
|
|
7070
|
+
}
|
|
7071
|
+
function contentType2(response) {
|
|
7072
|
+
return response.headers.get("content-type")?.split(";", 1)[0]?.trim() || "application/octet-stream";
|
|
7073
|
+
}
|
|
7074
|
+
function contentDispositionFilename2(value) {
|
|
7075
|
+
if (!value) return void 0;
|
|
7076
|
+
const extended = value.match(/filename\*\s*=\s*([^;]+)/iu)?.[1]?.trim().replace(/^"|"$/gu, "");
|
|
7077
|
+
if (extended) {
|
|
7078
|
+
const encoded = extended.replace(/^[^']*'[^']*'/u, "");
|
|
7079
|
+
try {
|
|
7080
|
+
return decodeURIComponent(encoded);
|
|
7081
|
+
} catch {
|
|
7082
|
+
return encoded;
|
|
7083
|
+
}
|
|
7084
|
+
}
|
|
7085
|
+
const quoted = value.match(/filename\s*=\s*"((?:\\.|[^"])*)"/iu)?.[1];
|
|
7086
|
+
if (quoted !== void 0) return quoted.replace(/\\([\\"])/gu, "$1");
|
|
7087
|
+
return value.match(/filename\s*=\s*([^;]+)/iu)?.[1]?.trim();
|
|
7088
|
+
}
|
|
7089
|
+
function urlFilename2(value) {
|
|
7090
|
+
try {
|
|
7091
|
+
const encoded = new URL(value).pathname.split("/").at(-1) ?? "";
|
|
7092
|
+
try {
|
|
7093
|
+
return decodeURIComponent(encoded);
|
|
7094
|
+
} catch {
|
|
7095
|
+
return encoded;
|
|
7096
|
+
}
|
|
7097
|
+
} catch {
|
|
7098
|
+
return void 0;
|
|
7099
|
+
}
|
|
7100
|
+
}
|
|
7101
|
+
function safeFilename(value) {
|
|
7102
|
+
const name = value?.split(/[\\/]/u).at(-1)?.replace(/[\u0000-\u001f\u007f]/gu, "").trim();
|
|
7103
|
+
return name && name !== "." && name !== ".." ? name : void 0;
|
|
7104
|
+
}
|
|
7105
|
+
function publicFileUrl(value) {
|
|
7106
|
+
const url = new URL(value);
|
|
7107
|
+
url.username = "";
|
|
7108
|
+
url.password = "";
|
|
7109
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
7110
|
+
if (key !== "forcedownload" && key !== "download") url.searchParams.delete(key);
|
|
7111
|
+
}
|
|
7112
|
+
return url.toString();
|
|
7113
|
+
}
|
|
7114
|
+
function encodeBase64(content) {
|
|
7115
|
+
let binary = "";
|
|
7116
|
+
for (let offset = 0; offset < content.byteLength; offset += 32768) {
|
|
7117
|
+
binary += String.fromCharCode(...content.subarray(offset, offset + 32768));
|
|
7118
|
+
}
|
|
7119
|
+
return btoa(binary);
|
|
7120
|
+
}
|
|
6856
7121
|
|
|
6857
7122
|
// src/mcp/server.ts
|
|
6858
7123
|
import { z as z3, ZodError } from "zod";
|
|
@@ -6865,14 +7130,122 @@ var READ_ONLY_ANNOTATIONS = {
|
|
|
6865
7130
|
};
|
|
6866
7131
|
var emptyInput = z3.object({}).strict();
|
|
6867
7132
|
var positiveId = z3.number().int().positive();
|
|
6868
|
-
var
|
|
6869
|
-
var
|
|
7133
|
+
var integer = z3.number().int();
|
|
7134
|
+
var userValue = z3.looseObject({
|
|
7135
|
+
userid: integer,
|
|
7136
|
+
username: z3.string(),
|
|
7137
|
+
fullname: z3.string(),
|
|
7138
|
+
sitename: z3.string(),
|
|
7139
|
+
siteurl: z3.string(),
|
|
7140
|
+
lang: z3.string().optional()
|
|
7141
|
+
});
|
|
7142
|
+
var courseValue = z3.looseObject({
|
|
7143
|
+
id: integer,
|
|
7144
|
+
shortname: z3.string(),
|
|
7145
|
+
fullname: z3.string(),
|
|
7146
|
+
category: z3.number().int(),
|
|
7147
|
+
visible: z3.boolean(),
|
|
7148
|
+
startdate: z3.number(),
|
|
7149
|
+
enddate: z3.number().optional()
|
|
7150
|
+
});
|
|
7151
|
+
var activityValue = z3.looseObject({
|
|
7152
|
+
id: integer,
|
|
7153
|
+
name: z3.string(),
|
|
7154
|
+
modname: z3.string(),
|
|
7155
|
+
url: z3.string(),
|
|
7156
|
+
visible: z3.boolean(),
|
|
7157
|
+
description: z3.string()
|
|
7158
|
+
});
|
|
7159
|
+
var fileEntryValue = z3.looseObject({
|
|
7160
|
+
name: z3.string(),
|
|
7161
|
+
url: z3.string(),
|
|
7162
|
+
requires_authentication: z3.boolean()
|
|
7163
|
+
});
|
|
7164
|
+
var activityDetailValue = z3.looseObject({
|
|
7165
|
+
id: positiveId,
|
|
7166
|
+
name: z3.string(),
|
|
7167
|
+
type: z3.string(),
|
|
7168
|
+
url: z3.string().optional(),
|
|
7169
|
+
target_name: z3.string().optional(),
|
|
7170
|
+
target_url: z3.string().optional(),
|
|
7171
|
+
file_entries: z3.array(fileEntryValue).optional()
|
|
7172
|
+
});
|
|
7173
|
+
var sectionValue = z3.looseObject({
|
|
7174
|
+
id: z3.number().int(),
|
|
7175
|
+
name: z3.string(),
|
|
7176
|
+
section: z3.number().int(),
|
|
7177
|
+
visible: z3.boolean(),
|
|
7178
|
+
summary: z3.string(),
|
|
7179
|
+
activities: z3.array(activityValue)
|
|
7180
|
+
});
|
|
7181
|
+
var todoValue = z3.looseObject({
|
|
7182
|
+
id: z3.number().int(),
|
|
7183
|
+
name: z3.string(),
|
|
7184
|
+
course_id: z3.number().int(),
|
|
7185
|
+
course_name: z3.string(),
|
|
7186
|
+
due_at: z3.number(),
|
|
7187
|
+
url: z3.string()
|
|
7188
|
+
});
|
|
7189
|
+
var gradeItemValue = z3.looseObject({
|
|
7190
|
+
name: z3.string(),
|
|
7191
|
+
item_type: z3.string(),
|
|
7192
|
+
grade: z3.string(),
|
|
7193
|
+
range: z3.string(),
|
|
7194
|
+
percentage: z3.string(),
|
|
7195
|
+
feedback: z3.string(),
|
|
7196
|
+
url: z3.string()
|
|
7197
|
+
});
|
|
7198
|
+
var gradesValue = z3.looseObject({
|
|
7199
|
+
course_id: integer,
|
|
7200
|
+
course_name: z3.string(),
|
|
7201
|
+
learner_name: z3.string(),
|
|
7202
|
+
total_grade: z3.string(),
|
|
7203
|
+
total_range: z3.string(),
|
|
7204
|
+
total_percentage: z3.string(),
|
|
7205
|
+
items: z3.array(gradeItemValue)
|
|
7206
|
+
});
|
|
7207
|
+
var forumValue = z3.looseObject({
|
|
7208
|
+
id: integer,
|
|
7209
|
+
name: z3.string(),
|
|
7210
|
+
course_id: integer,
|
|
7211
|
+
course_name: z3.string(),
|
|
7212
|
+
url: z3.string()
|
|
7213
|
+
});
|
|
7214
|
+
var forumSearchValue = z3.looseObject({
|
|
7215
|
+
course_id: integer,
|
|
7216
|
+
course_name: z3.string(),
|
|
7217
|
+
forum_id: integer,
|
|
7218
|
+
forum_name: z3.string(),
|
|
7219
|
+
discussion_id: integer,
|
|
7220
|
+
discussion_subject: z3.string(),
|
|
7221
|
+
post_id: integer,
|
|
7222
|
+
snippet: z3.string(),
|
|
7223
|
+
url: z3.string()
|
|
7224
|
+
});
|
|
7225
|
+
var forumPostValue = z3.looseObject({
|
|
7226
|
+
id: integer,
|
|
7227
|
+
discussion_id: integer,
|
|
7228
|
+
subject: z3.string(),
|
|
7229
|
+
message_text: z3.string(),
|
|
7230
|
+
author: z3.looseObject({ id: integer, fullname: z3.string() }),
|
|
7231
|
+
url: z3.string()
|
|
7232
|
+
});
|
|
7233
|
+
var threadValue = z3.looseObject({
|
|
7234
|
+
id: integer,
|
|
7235
|
+
subject: z3.string(),
|
|
7236
|
+
course_id: integer,
|
|
7237
|
+
forum_id: integer,
|
|
7238
|
+
group_id: z3.number().int(),
|
|
7239
|
+
group_name: z3.string(),
|
|
7240
|
+
url: z3.string(),
|
|
7241
|
+
posts: z3.array(forumPostValue)
|
|
7242
|
+
});
|
|
6870
7243
|
var TOOL_REGISTRATIONS = [
|
|
6871
7244
|
{
|
|
6872
7245
|
name: "get_user",
|
|
6873
7246
|
description: "Get the authenticated Moodle user and site.",
|
|
6874
7247
|
input: emptyInput,
|
|
6875
|
-
output: z3.object({ user:
|
|
7248
|
+
output: z3.object({ user: userValue })
|
|
6876
7249
|
},
|
|
6877
7250
|
{
|
|
6878
7251
|
name: "get_overview",
|
|
@@ -6882,19 +7255,31 @@ var TOOL_REGISTRATIONS = [
|
|
|
6882
7255
|
todoDays: z3.number().int().min(1).max(365).optional(),
|
|
6883
7256
|
alertsLimit: z3.number().int().min(1).max(100).optional().default(5)
|
|
6884
7257
|
}).strict(),
|
|
6885
|
-
output: z3.object({
|
|
7258
|
+
output: z3.object({
|
|
7259
|
+
overview: z3.looseObject({
|
|
7260
|
+
user: userValue,
|
|
7261
|
+
courses: z3.array(courseValue),
|
|
7262
|
+
todo: z3.array(todoValue),
|
|
7263
|
+
errors: z3.array(z3.string())
|
|
7264
|
+
})
|
|
7265
|
+
})
|
|
6886
7266
|
},
|
|
6887
7267
|
{
|
|
6888
7268
|
name: "list_courses",
|
|
6889
7269
|
description: "List the authenticated user's Moodle courses.",
|
|
6890
7270
|
input: z3.object({ limit: z3.number().int().min(1).max(200).optional().default(100) }).strict(),
|
|
6891
|
-
output: z3.object({ courses:
|
|
7271
|
+
output: z3.object({ courses: z3.array(courseValue) })
|
|
6892
7272
|
},
|
|
6893
7273
|
{
|
|
6894
7274
|
name: "get_course",
|
|
6895
7275
|
description: "Get one Moodle course and its sections.",
|
|
6896
7276
|
input: z3.object({ courseId: positiveId }).strict(),
|
|
6897
|
-
output: z3.object({
|
|
7277
|
+
output: z3.object({
|
|
7278
|
+
course: z3.looseObject({
|
|
7279
|
+
course: courseValue,
|
|
7280
|
+
sections: z3.array(sectionValue)
|
|
7281
|
+
})
|
|
7282
|
+
})
|
|
6898
7283
|
},
|
|
6899
7284
|
{
|
|
6900
7285
|
name: "list_activities",
|
|
@@ -6903,19 +7288,19 @@ var TOOL_REGISTRATIONS = [
|
|
|
6903
7288
|
courseId: positiveId,
|
|
6904
7289
|
limit: z3.number().int().min(1).max(200).optional().default(100)
|
|
6905
7290
|
}).strict(),
|
|
6906
|
-
output: z3.object({ activities:
|
|
7291
|
+
output: z3.object({ activities: z3.array(activityValue) })
|
|
6907
7292
|
},
|
|
6908
7293
|
{
|
|
6909
7294
|
name: "get_activity",
|
|
6910
7295
|
description: "Get the supported details for one Moodle activity.",
|
|
6911
7296
|
input: z3.object({ activityId: positiveId }).strict(),
|
|
6912
|
-
output: z3.object({ activity:
|
|
7297
|
+
output: z3.object({ activity: activityDetailValue })
|
|
6913
7298
|
},
|
|
6914
7299
|
{
|
|
6915
7300
|
name: "get_grades",
|
|
6916
7301
|
description: "Get the authenticated user's grades for one Moodle course.",
|
|
6917
7302
|
input: z3.object({ courseId: positiveId }).strict(),
|
|
6918
|
-
output: z3.object({ grades:
|
|
7303
|
+
output: z3.object({ grades: gradesValue })
|
|
6919
7304
|
},
|
|
6920
7305
|
{
|
|
6921
7306
|
name: "list_forums",
|
|
@@ -6924,7 +7309,7 @@ var TOOL_REGISTRATIONS = [
|
|
|
6924
7309
|
courseId: positiveId.optional(),
|
|
6925
7310
|
limit: z3.number().int().min(1).max(100).optional().default(50)
|
|
6926
7311
|
}).strict(),
|
|
6927
|
-
output: z3.object({ forums:
|
|
7312
|
+
output: z3.object({ forums: z3.array(forumValue) })
|
|
6928
7313
|
},
|
|
6929
7314
|
{
|
|
6930
7315
|
name: "search_forums",
|
|
@@ -6940,13 +7325,28 @@ var TOOL_REGISTRATIONS = [
|
|
|
6940
7325
|
maxForums: z3.number().int().min(1).max(50).optional(),
|
|
6941
7326
|
maxDiscussionsPerForum: z3.number().int().min(1).max(100).optional()
|
|
6942
7327
|
}).strict(),
|
|
6943
|
-
output: z3.object({ results:
|
|
7328
|
+
output: z3.object({ results: z3.array(forumSearchValue) })
|
|
6944
7329
|
},
|
|
6945
7330
|
{
|
|
6946
7331
|
name: "get_thread",
|
|
6947
7332
|
description: "Get one Moodle forum discussion and its posts.",
|
|
6948
7333
|
input: z3.object({ discussionId: positiveId }).strict(),
|
|
6949
|
-
output: z3.object({ thread:
|
|
7334
|
+
output: z3.object({ thread: threadValue })
|
|
7335
|
+
},
|
|
7336
|
+
{
|
|
7337
|
+
name: "get_file",
|
|
7338
|
+
description: "Fetch one authenticated Moodle file and return its content directly (maximum 16 MiB).",
|
|
7339
|
+
input: z3.object({
|
|
7340
|
+
source: z3.union([positiveId, z3.string().trim().min(1).max(2048)])
|
|
7341
|
+
}).strict(),
|
|
7342
|
+
output: z3.object({
|
|
7343
|
+
file: z3.object({
|
|
7344
|
+
name: z3.string(),
|
|
7345
|
+
mime_type: z3.string(),
|
|
7346
|
+
bytes: z3.number().int().nonnegative(),
|
|
7347
|
+
uri: z3.string()
|
|
7348
|
+
})
|
|
7349
|
+
})
|
|
6950
7350
|
}
|
|
6951
7351
|
];
|
|
6952
7352
|
var TOOL_CATALOG = TOOL_REGISTRATIONS.map(({ name, description, input, output }) => ({
|
|
@@ -7014,12 +7414,11 @@ function createMoodleMcpServer(gateway, options = {}) {
|
|
|
7014
7414
|
}
|
|
7015
7415
|
if (error instanceof UnsupportedProtocolVersionError) {
|
|
7016
7416
|
return jsonRpcFailure(id, {
|
|
7017
|
-
code: -
|
|
7018
|
-
message:
|
|
7417
|
+
code: -32022,
|
|
7418
|
+
message: "Unsupported protocol version",
|
|
7019
7419
|
data: {
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
supportedVersions: [...error.supportedVersions]
|
|
7420
|
+
supported: [...error.supportedVersions],
|
|
7421
|
+
requested: error.protocolVersion
|
|
7023
7422
|
}
|
|
7024
7423
|
});
|
|
7025
7424
|
}
|
|
@@ -7067,7 +7466,7 @@ async function callTool(gateway, params) {
|
|
|
7067
7466
|
const payload = await runGatewayTool(gateway, name, input);
|
|
7068
7467
|
const structuredContent = registration.output.parse(wrapToolOutput(name, payload));
|
|
7069
7468
|
return {
|
|
7070
|
-
content:
|
|
7469
|
+
content: toolContent(name, payload),
|
|
7071
7470
|
structuredContent,
|
|
7072
7471
|
resultType: "complete",
|
|
7073
7472
|
_meta: RESULT_META
|
|
@@ -7128,6 +7527,8 @@ async function runGatewayTool(gateway, name, input) {
|
|
|
7128
7527
|
});
|
|
7129
7528
|
case "get_thread":
|
|
7130
7529
|
return gateway.getThread({ discussionId: numberValue3(input.discussionId) });
|
|
7530
|
+
case "get_file":
|
|
7531
|
+
return gateway.getFile({ source: fileSource(input.source) });
|
|
7131
7532
|
default:
|
|
7132
7533
|
throw new McpCallError("TOOL_NOT_FOUND", `Unknown Moodle tool: ${name}`);
|
|
7133
7534
|
}
|
|
@@ -7145,8 +7546,33 @@ function wrapToolOutput(name, payload) {
|
|
|
7145
7546
|
search_forums: "results",
|
|
7146
7547
|
get_thread: "thread"
|
|
7147
7548
|
};
|
|
7549
|
+
if (name === "get_file" && isMoodleFile(payload)) {
|
|
7550
|
+
return {
|
|
7551
|
+
file: {
|
|
7552
|
+
name: payload.name,
|
|
7553
|
+
mime_type: payload.mimeType,
|
|
7554
|
+
bytes: payload.bytes,
|
|
7555
|
+
uri: payload.uri
|
|
7556
|
+
}
|
|
7557
|
+
};
|
|
7558
|
+
}
|
|
7148
7559
|
return { [keys[name] ?? "result"]: payload };
|
|
7149
7560
|
}
|
|
7561
|
+
function toolContent(name, payload) {
|
|
7562
|
+
const text = { type: "text", text: summarizeToolOutput(name, payload) };
|
|
7563
|
+
if (name !== "get_file" || !isMoodleFile(payload)) return [text];
|
|
7564
|
+
return [
|
|
7565
|
+
text,
|
|
7566
|
+
{
|
|
7567
|
+
type: "resource",
|
|
7568
|
+
resource: {
|
|
7569
|
+
uri: payload.uri,
|
|
7570
|
+
mimeType: payload.mimeType,
|
|
7571
|
+
blob: payload.blob
|
|
7572
|
+
}
|
|
7573
|
+
}
|
|
7574
|
+
];
|
|
7575
|
+
}
|
|
7150
7576
|
function summarizeToolOutput(name, payload) {
|
|
7151
7577
|
if (Array.isArray(payload)) {
|
|
7152
7578
|
const labels = {
|
|
@@ -7178,6 +7604,9 @@ function summarizeToolOutput(name, payload) {
|
|
|
7178
7604
|
if (name === "get_thread" && isRecord9(payload)) {
|
|
7179
7605
|
return `Loaded forum thread ${stringValue4(payload.subject) || numberValue3(payload.id)}.`;
|
|
7180
7606
|
}
|
|
7607
|
+
if (name === "get_file" && isMoodleFile(payload)) {
|
|
7608
|
+
return `Loaded Moodle file ${payload.name} (${payload.bytes} bytes).`;
|
|
7609
|
+
}
|
|
7181
7610
|
return "Moodle request completed.";
|
|
7182
7611
|
}
|
|
7183
7612
|
function mapMoodleError(error) {
|
|
@@ -7213,6 +7642,9 @@ function optionalNumber(value) {
|
|
|
7213
7642
|
function stringValue4(value) {
|
|
7214
7643
|
return typeof value === "string" ? value : "";
|
|
7215
7644
|
}
|
|
7645
|
+
function fileSource(value) {
|
|
7646
|
+
return typeof value === "number" || typeof value === "string" ? value : "";
|
|
7647
|
+
}
|
|
7216
7648
|
function booleanValue2(value) {
|
|
7217
7649
|
return value === true;
|
|
7218
7650
|
}
|
|
@@ -7222,6 +7654,9 @@ function enumValue(value, values) {
|
|
|
7222
7654
|
function isRecord9(value) {
|
|
7223
7655
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7224
7656
|
}
|
|
7657
|
+
function isMoodleFile(value) {
|
|
7658
|
+
return isRecord9(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
|
|
7659
|
+
}
|
|
7225
7660
|
|
|
7226
7661
|
// src/mcp/stdio.ts
|
|
7227
7662
|
async function serveMoodleMcpStdio(server, options) {
|