moodle-cli 0.7.0-alpha.3 → 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 +688 -126
- package/dist/worker/worker.js +1028 -568
- package/package.json +2 -1
- package/references/downloads.md +5 -1
package/dist/moodle.js
CHANGED
|
@@ -98,7 +98,7 @@ var MoodleAPIError = class extends CliError {
|
|
|
98
98
|
moodleErrorCode;
|
|
99
99
|
constructor(message, moodleErrorCode) {
|
|
100
100
|
const auth = isLoginErrorCode(moodleErrorCode);
|
|
101
|
-
const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") ||
|
|
101
|
+
const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") || /\bHTTP 404\b/.test(message);
|
|
102
102
|
super(auth ? "auth" : notFound ? "not_found" : "upstream", message, auth ? "Run `moodle auth login`." : void 0);
|
|
103
103
|
this.moodleErrorCode = moodleErrorCode;
|
|
104
104
|
}
|
|
@@ -898,6 +898,32 @@ function htmlText(value, baseUrl) {
|
|
|
898
898
|
|
|
899
899
|
// src/scraper.ts
|
|
900
900
|
import { parse as parse2 } from "node-html-parser";
|
|
901
|
+
function parseMoodleErrorHtml(html) {
|
|
902
|
+
const root = parse2(html);
|
|
903
|
+
const messageNode = first(root, [
|
|
904
|
+
".errormessage",
|
|
905
|
+
".alert-danger .alert-message",
|
|
906
|
+
"[data-region='error-message']",
|
|
907
|
+
".alert-danger[role='alert']",
|
|
908
|
+
".alert-danger"
|
|
909
|
+
]);
|
|
910
|
+
if (!messageNode) {
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
const messageRoot = parse2(messageNode.toString());
|
|
914
|
+
for (const unwanted of messageRoot.querySelectorAll(
|
|
915
|
+
"button, .close, .errorcode, .stacktrace, .debuginfo, .backtrace, a.alert-link, a[href*='/error/']"
|
|
916
|
+
)) {
|
|
917
|
+
unwanted.remove();
|
|
918
|
+
}
|
|
919
|
+
const message = cleanText(messageRoot.textContent);
|
|
920
|
+
if (!message) {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
const errorCodeText = cleanNodeText(root.querySelector(".errorcode"));
|
|
924
|
+
const errorCode = errorCodeText.match(/^error\s+code\s*:\s*([a-z][a-z0-9_]*)\s*$/iu)?.[1] ?? moodleDocsErrorCode(root);
|
|
925
|
+
return { message, ...errorCode ? { code: errorCode } : {} };
|
|
926
|
+
}
|
|
901
927
|
function parsePageContext(html, baseUrl) {
|
|
902
928
|
const root = parse2(html);
|
|
903
929
|
const config = parseMoodleConfig(html);
|
|
@@ -984,9 +1010,9 @@ function parseCourseSectionNumbers(html, courseId) {
|
|
|
984
1010
|
for (const href of hrefs) {
|
|
985
1011
|
const url = parseMaybeUrl(href.replace(/&/gu, "&"), "https://moodle.invalid");
|
|
986
1012
|
const id = url?.searchParams.get("id");
|
|
987
|
-
const
|
|
988
|
-
if (id === String(courseId) &&
|
|
989
|
-
const section = Number(
|
|
1013
|
+
const sectionValue2 = url?.searchParams.get("section");
|
|
1014
|
+
if (id === String(courseId) && sectionValue2 && /^\d+$/.test(sectionValue2)) {
|
|
1015
|
+
const section = Number(sectionValue2);
|
|
990
1016
|
if (!sections.includes(section)) {
|
|
991
1017
|
sections.push(section);
|
|
992
1018
|
}
|
|
@@ -1329,6 +1355,16 @@ function selectedGroupName(root, groupId) {
|
|
|
1329
1355
|
}
|
|
1330
1356
|
return "";
|
|
1331
1357
|
}
|
|
1358
|
+
function moodleDocsErrorCode(root) {
|
|
1359
|
+
for (const link2 of root.querySelectorAll("a[href*='/error/']")) {
|
|
1360
|
+
const href = link2.getAttribute("href") ?? "";
|
|
1361
|
+
const code = href.match(/\/error\/[^/]+\/([a-z][a-z0-9_]*)/iu)?.[1];
|
|
1362
|
+
if (code) {
|
|
1363
|
+
return code;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return void 0;
|
|
1367
|
+
}
|
|
1332
1368
|
function cleanNodeText(node) {
|
|
1333
1369
|
return cleanText(node?.textContent ?? "");
|
|
1334
1370
|
}
|
|
@@ -1818,7 +1854,7 @@ var MoodleClientCoreApiError = class extends MoodleClientCoreError {
|
|
|
1818
1854
|
moodleErrorCode;
|
|
1819
1855
|
constructor(message, moodleErrorCode) {
|
|
1820
1856
|
const auth = isLoginErrorCode2(moodleErrorCode);
|
|
1821
|
-
const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") ||
|
|
1857
|
+
const notFound = ["invalidrecord", "invalidcoursemodule"].includes(moodleErrorCode ?? "") || /\bHTTP 404\b/.test(message);
|
|
1822
1858
|
super(
|
|
1823
1859
|
auth ? "auth" : notFound ? "not_found" : "upstream",
|
|
1824
1860
|
message,
|
|
@@ -2249,7 +2285,15 @@ var MoodleClientCore = class {
|
|
|
2249
2285
|
throw this.errors.api("Session expired", "servicerequireslogin");
|
|
2250
2286
|
}
|
|
2251
2287
|
if (!response.ok) {
|
|
2252
|
-
|
|
2288
|
+
const context = `HTTP ${response.status} loading ${safeUrl(url)}`;
|
|
2289
|
+
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
2290
|
+
if (contentType3.includes("text/html") || contentType3.includes("application/xhtml+xml")) {
|
|
2291
|
+
const moodleError = await response.text().then(parseMoodleErrorHtml).catch(() => null);
|
|
2292
|
+
if (moodleError) {
|
|
2293
|
+
throw this.errors.api(`${moodleError.message} (${context})`, moodleError.code);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
throw this.errors.api(context);
|
|
2253
2297
|
}
|
|
2254
2298
|
return response;
|
|
2255
2299
|
}
|
|
@@ -2625,8 +2669,8 @@ async function probeBaseUrl(baseUrl, options = {}) {
|
|
|
2625
2669
|
return { ok: false, message: `Could not reach ${baseUrl}: ${error instanceof Error ? error.message : String(error)}` };
|
|
2626
2670
|
}
|
|
2627
2671
|
const body = (await response.text()).slice(0, 5e3).toLowerCase();
|
|
2628
|
-
const
|
|
2629
|
-
const looksJson =
|
|
2672
|
+
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
2673
|
+
const looksJson = contentType3.includes("application/json") || body.startsWith("{");
|
|
2630
2674
|
const looksMoodleTokenError = [
|
|
2631
2675
|
'"errorcode":"missingparam"',
|
|
2632
2676
|
'"errorcode":"invalidparameter"',
|
|
@@ -2715,84 +2759,163 @@ function isMissingFileError2(error) {
|
|
|
2715
2759
|
return isRecord5(error) && error.code === "ENOENT";
|
|
2716
2760
|
}
|
|
2717
2761
|
|
|
2762
|
+
// src/terminal-table.ts
|
|
2763
|
+
import Table from "tty-table";
|
|
2764
|
+
var DEFAULT_WIDTH = 120;
|
|
2765
|
+
var MIN_TABLE_WIDTH = 40;
|
|
2766
|
+
function renderTerminalTable(columns, rows, options = {}) {
|
|
2767
|
+
const tableWidth = Math.max(MIN_TABLE_WIDTH, options.width ?? process.stdout.columns ?? DEFAULT_WIDTH);
|
|
2768
|
+
const tableColumns = columns.map((column, index) => ({
|
|
2769
|
+
align: "left",
|
|
2770
|
+
alias: sanitizeTerminalText(column.label),
|
|
2771
|
+
headerAlign: "left",
|
|
2772
|
+
value: `column_${index}`
|
|
2773
|
+
}));
|
|
2774
|
+
const data = rows.map((row) => Object.fromEntries(
|
|
2775
|
+
tableColumns.map((column, index) => [column.value, sanitizeTerminalText(row[index] ?? "")])
|
|
2776
|
+
));
|
|
2777
|
+
const tableOptions = {
|
|
2778
|
+
COLUMNS: tableWidth,
|
|
2779
|
+
compact: true,
|
|
2780
|
+
marginLeft: 0,
|
|
2781
|
+
marginTop: 0,
|
|
2782
|
+
width: String(tableWidth)
|
|
2783
|
+
};
|
|
2784
|
+
const output = Table(tableColumns, data, tableOptions).render();
|
|
2785
|
+
return [options.title ? sanitizeTerminalText(options.title) : "", output].filter(Boolean).join("\n");
|
|
2786
|
+
}
|
|
2787
|
+
function renderKeyValueTable(rows, options = {}) {
|
|
2788
|
+
const present = rows.filter(([, value]) => value !== "");
|
|
2789
|
+
if (present.length === 0) {
|
|
2790
|
+
return options.title ? `${options.title}
|
|
2791
|
+
No details` : "No details";
|
|
2792
|
+
}
|
|
2793
|
+
const columns = [
|
|
2794
|
+
{ label: "Field" },
|
|
2795
|
+
{ label: "Value" }
|
|
2796
|
+
];
|
|
2797
|
+
return renderTerminalTable(columns, present, options);
|
|
2798
|
+
}
|
|
2799
|
+
function sanitizeTerminalText(value) {
|
|
2800
|
+
return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu, "");
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2718
2803
|
// src/formatters.ts
|
|
2719
2804
|
function formatUser(user) {
|
|
2720
|
-
return
|
|
2805
|
+
return renderKeyValueTable([
|
|
2721
2806
|
["User", user.fullname],
|
|
2722
2807
|
["Username", user.username],
|
|
2723
2808
|
["User ID", String(user.userid)],
|
|
2724
2809
|
["Site", user.sitename],
|
|
2725
2810
|
["URL", user.siteurl],
|
|
2726
2811
|
["Language", user.lang ?? ""]
|
|
2727
|
-
]);
|
|
2812
|
+
], { title: "User" });
|
|
2728
2813
|
}
|
|
2729
2814
|
function formatCourses(courses) {
|
|
2730
|
-
return
|
|
2815
|
+
return renderTerminalTable(
|
|
2816
|
+
[
|
|
2817
|
+
{ label: "ID" },
|
|
2818
|
+
{ label: "Short Name" },
|
|
2819
|
+
{ label: "Full Name" }
|
|
2820
|
+
],
|
|
2821
|
+
courses.map((course) => [String(course.id), course.shortname, course.fullname]),
|
|
2822
|
+
{ title: "Enrolled Units" }
|
|
2823
|
+
);
|
|
2731
2824
|
}
|
|
2732
2825
|
function formatCourseSections(sections) {
|
|
2733
2826
|
const lines = ["Course"];
|
|
2734
|
-
for (const section of sections) {
|
|
2735
|
-
|
|
2827
|
+
for (const [sectionIndex, section] of sections.entries()) {
|
|
2828
|
+
const sectionLast = sectionIndex === sections.length - 1;
|
|
2829
|
+
lines.push(`${sectionLast ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"} ${section.name || `Section ${section.section}`}${section.visible ? "" : " (hidden)"}`);
|
|
2736
2830
|
if (!section.activities.length) {
|
|
2737
|
-
lines.push(" No activities
|
|
2831
|
+
lines.push(`${sectionLast ? " " : "\u2502 "}\u2514\u2500\u2500 No activities`);
|
|
2738
2832
|
continue;
|
|
2739
2833
|
}
|
|
2740
|
-
for (const activity of section.activities) {
|
|
2741
|
-
|
|
2834
|
+
for (const [activityIndex, activity] of section.activities.entries()) {
|
|
2835
|
+
const activityPrefix = activityIndex === section.activities.length - 1 ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500";
|
|
2836
|
+
lines.push(`${sectionLast ? " " : "\u2502 "}${activityPrefix} ${activity.name}${activity.visible ? "" : " (hidden)"} (${activity.modname})`);
|
|
2742
2837
|
}
|
|
2743
2838
|
}
|
|
2744
|
-
return lines.join("\n");
|
|
2839
|
+
return sanitizeTerminalText(lines.join("\n"));
|
|
2745
2840
|
}
|
|
2746
2841
|
function formatActivityList(value) {
|
|
2747
2842
|
const activities = Array.isArray(value) && value[0] && "activities" in value[0] ? value.flatMap((section) => section.activities) : value;
|
|
2748
|
-
return
|
|
2843
|
+
return renderTerminalTable(
|
|
2844
|
+
[
|
|
2845
|
+
{ label: "ID" },
|
|
2846
|
+
{ label: "Type" },
|
|
2847
|
+
{ label: "Name" }
|
|
2848
|
+
],
|
|
2849
|
+
activities.map((activity) => [String(activity.id), activity.modname, activity.name]),
|
|
2850
|
+
{ title: "Activities" }
|
|
2851
|
+
);
|
|
2749
2852
|
}
|
|
2750
2853
|
function formatTodo(items) {
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2854
|
+
const columns = [
|
|
2855
|
+
{ label: "Due" },
|
|
2856
|
+
{ label: "Unit" },
|
|
2857
|
+
{ label: "Activity" },
|
|
2858
|
+
{ label: "Type" },
|
|
2859
|
+
{ label: "Action" }
|
|
2860
|
+
];
|
|
2861
|
+
const rows = items.length ? items.map((item) => [
|
|
2862
|
+
`${item.overdue ? "Overdue \xB7 " : ""}${formatTimestamp(item.due_at)}`,
|
|
2863
|
+
`${item.course_name}${item.course_progress === void 0 ? "" : ` (${item.course_progress}%)`}`,
|
|
2864
|
+
item.activity_name || item.name,
|
|
2865
|
+
item.modname || item.event_type,
|
|
2866
|
+
item.actionable ? item.action_name : ""
|
|
2867
|
+
]) : [["No upcoming items", "", "", "", ""]];
|
|
2868
|
+
return renderTerminalTable(columns, rows, { title: "Todo" });
|
|
2764
2869
|
}
|
|
2765
2870
|
function formatAlerts(alerts) {
|
|
2766
|
-
const
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
];
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2871
|
+
const summary = renderKeyValueTable([
|
|
2872
|
+
["Notifications", String(alerts.notification_count)],
|
|
2873
|
+
["Unread notifications", String(alerts.unread_notification_count)],
|
|
2874
|
+
["Direct messages", String(alerts.direct_message_count)],
|
|
2875
|
+
["Unread direct messages", String(alerts.unread_direct_message_count)]
|
|
2876
|
+
], { title: "Alerts" });
|
|
2877
|
+
if (!alerts.notifications.length) return summary;
|
|
2878
|
+
const notifications = renderTerminalTable(
|
|
2879
|
+
[
|
|
2880
|
+
{ label: "When" },
|
|
2881
|
+
{ label: "Subject" }
|
|
2882
|
+
],
|
|
2883
|
+
alerts.notifications.map((notification) => [
|
|
2884
|
+
notification.created_pretty || formatTimestamp(notification.created_at),
|
|
2885
|
+
notification.short_subject || notification.subject
|
|
2886
|
+
]),
|
|
2887
|
+
{ title: "Notifications" }
|
|
2888
|
+
);
|
|
2889
|
+
return `${summary}
|
|
2890
|
+
|
|
2891
|
+
${notifications}`;
|
|
2776
2892
|
}
|
|
2777
2893
|
function formatGrades(grades) {
|
|
2778
|
-
return
|
|
2779
|
-
[
|
|
2780
|
-
|
|
2781
|
-
|
|
2894
|
+
return renderTerminalTable(
|
|
2895
|
+
[
|
|
2896
|
+
{ label: "Item" },
|
|
2897
|
+
{ label: "Grade" },
|
|
2898
|
+
{ label: "Range" },
|
|
2899
|
+
{ label: "Percent" },
|
|
2900
|
+
{ label: "Feedback" }
|
|
2901
|
+
],
|
|
2902
|
+
grades.items.map((item) => [item.name, item.grade, item.range, item.percentage, item.feedback]),
|
|
2903
|
+
{ title: grades.course_name ? `Grades \xB7 ${grades.course_name}` : "Grades" }
|
|
2904
|
+
);
|
|
2782
2905
|
}
|
|
2783
2906
|
function formatActivityDetail(activity) {
|
|
2784
2907
|
const rows = Object.entries(activity).filter(([, value]) => value !== "" && value !== void 0 && !(Array.isArray(value) && value.length === 0)).map(([key, value]) => [key, Array.isArray(value) ? value.join("\n") : String(value)]);
|
|
2785
|
-
return
|
|
2908
|
+
return renderKeyValueTable(rows, { title: "Activity" });
|
|
2786
2909
|
}
|
|
2787
2910
|
function formatDownloadReceipt(receipt) {
|
|
2788
|
-
return
|
|
2911
|
+
return renderKeyValueTable([
|
|
2789
2912
|
["File", receipt.file_path],
|
|
2790
2913
|
["Filename", receipt.filename],
|
|
2791
2914
|
["Bytes", String(receipt.bytes_written)],
|
|
2792
2915
|
["Content type", receipt.content_type],
|
|
2793
2916
|
["Source", receipt.source_url],
|
|
2794
2917
|
["Final URL", receipt.final_url]
|
|
2795
|
-
]);
|
|
2918
|
+
], { title: "Download" });
|
|
2796
2919
|
}
|
|
2797
2920
|
function formatForumDiscussion(discussion, options = {}) {
|
|
2798
2921
|
const lines = [`Discussion: ${discussion.id}`];
|
|
@@ -2810,13 +2933,13 @@ function formatForumDiscussion(discussion, options = {}) {
|
|
|
2810
2933
|
}
|
|
2811
2934
|
if (!discussion.posts.length) {
|
|
2812
2935
|
lines.push("", "No posts");
|
|
2813
|
-
return lines.join("\n");
|
|
2936
|
+
return sanitizeTerminalText(lines.join("\n"));
|
|
2814
2937
|
}
|
|
2815
2938
|
for (const post of discussion.posts) {
|
|
2816
2939
|
const marker = options.highlightPostId === post.id ? "*" : "-";
|
|
2817
2940
|
lines.push("", `${marker} Post ${post.id}`);
|
|
2818
2941
|
lines.push(` Author: ${post.author.fullname || "-"}`);
|
|
2819
|
-
lines.push(` When: ${post.created_pretty || (post.time_created
|
|
2942
|
+
lines.push(` When: ${post.created_pretty || formatTimestamp(post.time_created)}`);
|
|
2820
2943
|
if (post.subject) {
|
|
2821
2944
|
lines.push(` Subject: ${post.subject}`);
|
|
2822
2945
|
}
|
|
@@ -2835,58 +2958,68 @@ function formatForumDiscussion(discussion, options = {}) {
|
|
|
2835
2958
|
lines.push(` Images: ${post.image_urls.length}`);
|
|
2836
2959
|
}
|
|
2837
2960
|
}
|
|
2838
|
-
return lines.join("\n");
|
|
2961
|
+
return sanitizeTerminalText(lines.join("\n"));
|
|
2839
2962
|
}
|
|
2840
2963
|
function formatForumDiscussionRefs(forumCmid, refs) {
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2964
|
+
return renderTerminalTable(
|
|
2965
|
+
[
|
|
2966
|
+
{ label: "ID" },
|
|
2967
|
+
{ label: "Subject" },
|
|
2968
|
+
{ label: "Group" },
|
|
2969
|
+
{ label: "URL" }
|
|
2970
|
+
],
|
|
2971
|
+
refs.length ? refs.map((ref) => [String(ref.id), ref.subject, ref.group_name, ref.url]) : [["No discussions", "", "", ""]],
|
|
2972
|
+
{ title: `Forum ${forumCmid} \xB7 Discussions` }
|
|
2973
|
+
);
|
|
2850
2974
|
}
|
|
2851
2975
|
function formatForumActivities(forums) {
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2976
|
+
return renderTerminalTable(
|
|
2977
|
+
[
|
|
2978
|
+
{ label: "ID" },
|
|
2979
|
+
{ label: "Forum" },
|
|
2980
|
+
{ label: "Unit" },
|
|
2981
|
+
{ label: "URL" }
|
|
2982
|
+
],
|
|
2983
|
+
forums.length ? forums.map((forum) => [String(forum.id), forum.name, forum.course_name, forum.url]) : [["No forums", "", "", ""]],
|
|
2984
|
+
{ title: "Forums" }
|
|
2985
|
+
);
|
|
2859
2986
|
}
|
|
2860
2987
|
function formatForumSearchHits(hits) {
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2988
|
+
return renderTerminalTable(
|
|
2989
|
+
[
|
|
2990
|
+
{ label: "Discussion" },
|
|
2991
|
+
{ label: "Post" },
|
|
2992
|
+
{ label: "Unit" },
|
|
2993
|
+
{ label: "Forum" },
|
|
2994
|
+
{ label: "Subject" },
|
|
2995
|
+
{ label: "Author" },
|
|
2996
|
+
{ label: "Match" },
|
|
2997
|
+
{ label: "Snippet" },
|
|
2998
|
+
{ label: "URL" }
|
|
2999
|
+
],
|
|
3000
|
+
hits.length ? hits.map((hit) => [
|
|
3001
|
+
String(hit.discussion_id),
|
|
3002
|
+
String(hit.post_id),
|
|
3003
|
+
hit.course_name,
|
|
3004
|
+
hit.forum_name,
|
|
3005
|
+
hit.discussion_subject,
|
|
3006
|
+
hit.author_name,
|
|
3007
|
+
hit.matched_in,
|
|
3008
|
+
hit.snippet || hit.discussion_subject,
|
|
3009
|
+
hit.url
|
|
3010
|
+
]) : [["No matches", "", "", "", "", "", "", "", ""]],
|
|
3011
|
+
{ title: "Forum Search" }
|
|
3012
|
+
);
|
|
2880
3013
|
}
|
|
2881
3014
|
function formatAuthStatus(status) {
|
|
2882
|
-
return
|
|
3015
|
+
return renderKeyValueTable([
|
|
2883
3016
|
["Site", status.base_url],
|
|
2884
3017
|
["Cached session", status.session_cached ? "yes" : "no"],
|
|
2885
3018
|
["Cache age", status.cache_age_minutes === null ? "" : `${status.cache_age_minutes} min`],
|
|
2886
3019
|
["Session alive", status.session_alive === null ? status.session_cached ? "unknown" : "" : status.session_alive ? "yes" : "no"],
|
|
2887
3020
|
["Server timeout in", formatDuration(status.session_time_remaining_seconds)],
|
|
2888
3021
|
["Keepalive agent", status.keepalive_installed ? `installed (${status.keepalive_plist_path})` : "not installed"]
|
|
2889
|
-
]);
|
|
3022
|
+
], { title: "Authentication" });
|
|
2890
3023
|
}
|
|
2891
3024
|
function formatKeepaliveResult(result) {
|
|
2892
3025
|
switch (result.status) {
|
|
@@ -2919,17 +3052,11 @@ function preview(value, maxLen = 100) {
|
|
|
2919
3052
|
const cleaned = value.split(/\s+/).filter(Boolean).join(" ");
|
|
2920
3053
|
return cleaned.length <= maxLen ? cleaned : `${cleaned.slice(0, maxLen - 1)}\u2026`;
|
|
2921
3054
|
}
|
|
2922
|
-
function
|
|
2923
|
-
|
|
2924
|
-
const
|
|
2925
|
-
|
|
2926
|
-
}
|
|
2927
|
-
function formatColumns(rows) {
|
|
2928
|
-
if (!rows.length) {
|
|
2929
|
-
return "";
|
|
2930
|
-
}
|
|
2931
|
-
const widths = rows[0].map((_, index) => Math.max(...rows.map((row) => (row[index] ?? "").length)));
|
|
2932
|
-
return rows.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd()).join("\n");
|
|
3055
|
+
function formatTimestamp(value) {
|
|
3056
|
+
if (value <= 0) return "-";
|
|
3057
|
+
const date = new Date(value * 1e3);
|
|
3058
|
+
const pad = (part) => String(part).padStart(2, "0");
|
|
3059
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
2933
3060
|
}
|
|
2934
3061
|
|
|
2935
3062
|
// src/download.ts
|
|
@@ -3700,7 +3827,7 @@ function escapeXml(value) {
|
|
|
3700
3827
|
}
|
|
3701
3828
|
|
|
3702
3829
|
// src/version.ts
|
|
3703
|
-
var VERSION = "0.7.0-alpha.
|
|
3830
|
+
var VERSION = "0.7.0-alpha.5";
|
|
3704
3831
|
|
|
3705
3832
|
// src/forum.ts
|
|
3706
3833
|
function parseDiscussionReference(value) {
|
|
@@ -3734,11 +3861,11 @@ async function parseForumReference(value, resolveForumCmid) {
|
|
|
3734
3861
|
throw new UsageError("FORUM must be a numeric ID or a full forum URL.");
|
|
3735
3862
|
}
|
|
3736
3863
|
if (url.pathname.endsWith("/mod/forum/view.php")) {
|
|
3737
|
-
const
|
|
3738
|
-
if (!
|
|
3864
|
+
const forumValue2 = url.searchParams.get("id");
|
|
3865
|
+
if (!forumValue2 || !/^\d+$/.test(forumValue2)) {
|
|
3739
3866
|
throw new UsageError("Could not find forum module ID in view.php URL (expected ?id=...).");
|
|
3740
3867
|
}
|
|
3741
|
-
return Number(
|
|
3868
|
+
return Number(forumValue2);
|
|
3742
3869
|
}
|
|
3743
3870
|
if (url.pathname.endsWith("/mod/forum/discuss.php")) {
|
|
3744
3871
|
const discussionValue = url.searchParams.get("d");
|
|
@@ -3998,11 +4125,12 @@ async function bridgeRemoteMcp(options) {
|
|
|
3998
4125
|
}
|
|
3999
4126
|
if (response.status === 202 || request.id === void 0) return;
|
|
4000
4127
|
if (!response.ok) {
|
|
4128
|
+
if (await forwardProtocolNegotiationError(options.output, response, request.id)) return;
|
|
4001
4129
|
await writeRemoteError(options.output, request.id, response.status);
|
|
4002
4130
|
return;
|
|
4003
4131
|
}
|
|
4004
|
-
const
|
|
4005
|
-
if (
|
|
4132
|
+
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
4133
|
+
if (contentType3.includes("text/event-stream")) {
|
|
4006
4134
|
const forwarded = await writeSseMessages(options.output, await response.text(), request.id);
|
|
4007
4135
|
if (forwarded) negotiatedProtocolVersion = initializedProtocolVersion(request) ?? negotiatedProtocolVersion;
|
|
4008
4136
|
return;
|
|
@@ -4077,6 +4205,30 @@ async function writeRemoteError(output, id, status) {
|
|
|
4077
4205
|
data: { type: "REMOTE_MCP_ERROR", status }
|
|
4078
4206
|
}));
|
|
4079
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
|
+
}
|
|
4080
4232
|
async function writeJson(output, value) {
|
|
4081
4233
|
await output.write(`${JSON.stringify(value)}
|
|
4082
4234
|
`);
|
|
@@ -6187,7 +6339,9 @@ ${error.stderr}`)) {
|
|
|
6187
6339
|
throw new DeploymentApplyError("INITIAL_WORKER_INVALID", "Wrangler did not return the initialized Worker");
|
|
6188
6340
|
}
|
|
6189
6341
|
const productionEndpoint = firstWorkersDevUrl([result.stdout, result.stderr]);
|
|
6190
|
-
|
|
6342
|
+
const initialized = productionEndpoint ? { ...worker, productionEndpoint } : worker;
|
|
6343
|
+
await pinExpectedHosts(input.configPath, input.workerName, initialized.productionEndpoint);
|
|
6344
|
+
return initialized;
|
|
6191
6345
|
} catch (error) {
|
|
6192
6346
|
const worker = await this.inspect(input.accountId, input.workerName).catch(() => null);
|
|
6193
6347
|
if (worker || result) {
|
|
@@ -6285,6 +6439,7 @@ var NodeReleaseMaterializer = class {
|
|
|
6285
6439
|
await copyFile(this.options.workerBundlePath, workerFile);
|
|
6286
6440
|
const wranglerConfigPath = join7(artifactDirectory, "wrangler.json");
|
|
6287
6441
|
const secretsFilePath = join7(artifactDirectory, "secrets.json");
|
|
6442
|
+
const expectedHosts = endpointHosts(plan.intent.workerName, plan.existing?.productionEndpoint);
|
|
6288
6443
|
const config = {
|
|
6289
6444
|
$schema: "node_modules/wrangler/config-schema.json",
|
|
6290
6445
|
name: plan.intent.workerName,
|
|
@@ -6292,7 +6447,10 @@ var NodeReleaseMaterializer = class {
|
|
|
6292
6447
|
main: `./${basename(workerFile)}`,
|
|
6293
6448
|
compatibility_date: this.options.compatibilityDate,
|
|
6294
6449
|
preview_urls: true,
|
|
6295
|
-
vars: {
|
|
6450
|
+
vars: {
|
|
6451
|
+
MOODLE_ORIGIN: plan.intent.moodleOrigin,
|
|
6452
|
+
...expectedHosts.length ? { EXPECTED_HOSTS: expectedHosts.join(",") } : {}
|
|
6453
|
+
},
|
|
6296
6454
|
durable_objects: {
|
|
6297
6455
|
bindings: [{ name: "SESSION_BROKER", class_name: "SessionBroker" }]
|
|
6298
6456
|
},
|
|
@@ -6544,6 +6702,35 @@ function ownershipId(accountId, workerName) {
|
|
|
6544
6702
|
function endpointUrl(endpoint, path4) {
|
|
6545
6703
|
return `${endpoint.replace(/\/$/u, "")}${path4}`;
|
|
6546
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
|
+
}
|
|
6547
6734
|
function isRetryableSessionUpload(status) {
|
|
6548
6735
|
return status === 401 || isRetryableWorkerPropagation(status);
|
|
6549
6736
|
}
|
|
@@ -6688,6 +6875,8 @@ function isMissing4(error) {
|
|
|
6688
6875
|
}
|
|
6689
6876
|
|
|
6690
6877
|
// src/mcp/gateway.ts
|
|
6878
|
+
import { parse as parse4 } from "node-html-parser";
|
|
6879
|
+
var MAX_MCP_FILE_BYTES = 16 * 1024 * 1024;
|
|
6691
6880
|
var MoodleGatewayError = class extends Error {
|
|
6692
6881
|
code;
|
|
6693
6882
|
constructor(code, message) {
|
|
@@ -6723,9 +6912,212 @@ function createMoodleGateway(client) {
|
|
|
6723
6912
|
return limit === void 0 ? forums : forums.slice(0, limit);
|
|
6724
6913
|
},
|
|
6725
6914
|
searchForums: ({ forumId, ...input }) => client.searchForumContent({ ...input, forumCmid: forumId }),
|
|
6726
|
-
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
|
+
}
|
|
6727
6952
|
};
|
|
6728
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
|
+
}
|
|
6729
7121
|
|
|
6730
7122
|
// src/mcp/server.ts
|
|
6731
7123
|
import { z as z3, ZodError } from "zod";
|
|
@@ -6738,14 +7130,122 @@ var READ_ONLY_ANNOTATIONS = {
|
|
|
6738
7130
|
};
|
|
6739
7131
|
var emptyInput = z3.object({}).strict();
|
|
6740
7132
|
var positiveId = z3.number().int().positive();
|
|
6741
|
-
var
|
|
6742
|
-
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
|
+
});
|
|
6743
7243
|
var TOOL_REGISTRATIONS = [
|
|
6744
7244
|
{
|
|
6745
7245
|
name: "get_user",
|
|
6746
7246
|
description: "Get the authenticated Moodle user and site.",
|
|
6747
7247
|
input: emptyInput,
|
|
6748
|
-
output: z3.object({ user:
|
|
7248
|
+
output: z3.object({ user: userValue })
|
|
6749
7249
|
},
|
|
6750
7250
|
{
|
|
6751
7251
|
name: "get_overview",
|
|
@@ -6755,19 +7255,31 @@ var TOOL_REGISTRATIONS = [
|
|
|
6755
7255
|
todoDays: z3.number().int().min(1).max(365).optional(),
|
|
6756
7256
|
alertsLimit: z3.number().int().min(1).max(100).optional().default(5)
|
|
6757
7257
|
}).strict(),
|
|
6758
|
-
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
|
+
})
|
|
6759
7266
|
},
|
|
6760
7267
|
{
|
|
6761
7268
|
name: "list_courses",
|
|
6762
7269
|
description: "List the authenticated user's Moodle courses.",
|
|
6763
7270
|
input: z3.object({ limit: z3.number().int().min(1).max(200).optional().default(100) }).strict(),
|
|
6764
|
-
output: z3.object({ courses:
|
|
7271
|
+
output: z3.object({ courses: z3.array(courseValue) })
|
|
6765
7272
|
},
|
|
6766
7273
|
{
|
|
6767
7274
|
name: "get_course",
|
|
6768
7275
|
description: "Get one Moodle course and its sections.",
|
|
6769
7276
|
input: z3.object({ courseId: positiveId }).strict(),
|
|
6770
|
-
output: z3.object({
|
|
7277
|
+
output: z3.object({
|
|
7278
|
+
course: z3.looseObject({
|
|
7279
|
+
course: courseValue,
|
|
7280
|
+
sections: z3.array(sectionValue)
|
|
7281
|
+
})
|
|
7282
|
+
})
|
|
6771
7283
|
},
|
|
6772
7284
|
{
|
|
6773
7285
|
name: "list_activities",
|
|
@@ -6776,19 +7288,19 @@ var TOOL_REGISTRATIONS = [
|
|
|
6776
7288
|
courseId: positiveId,
|
|
6777
7289
|
limit: z3.number().int().min(1).max(200).optional().default(100)
|
|
6778
7290
|
}).strict(),
|
|
6779
|
-
output: z3.object({ activities:
|
|
7291
|
+
output: z3.object({ activities: z3.array(activityValue) })
|
|
6780
7292
|
},
|
|
6781
7293
|
{
|
|
6782
7294
|
name: "get_activity",
|
|
6783
7295
|
description: "Get the supported details for one Moodle activity.",
|
|
6784
7296
|
input: z3.object({ activityId: positiveId }).strict(),
|
|
6785
|
-
output: z3.object({ activity:
|
|
7297
|
+
output: z3.object({ activity: activityDetailValue })
|
|
6786
7298
|
},
|
|
6787
7299
|
{
|
|
6788
7300
|
name: "get_grades",
|
|
6789
7301
|
description: "Get the authenticated user's grades for one Moodle course.",
|
|
6790
7302
|
input: z3.object({ courseId: positiveId }).strict(),
|
|
6791
|
-
output: z3.object({ grades:
|
|
7303
|
+
output: z3.object({ grades: gradesValue })
|
|
6792
7304
|
},
|
|
6793
7305
|
{
|
|
6794
7306
|
name: "list_forums",
|
|
@@ -6797,7 +7309,7 @@ var TOOL_REGISTRATIONS = [
|
|
|
6797
7309
|
courseId: positiveId.optional(),
|
|
6798
7310
|
limit: z3.number().int().min(1).max(100).optional().default(50)
|
|
6799
7311
|
}).strict(),
|
|
6800
|
-
output: z3.object({ forums:
|
|
7312
|
+
output: z3.object({ forums: z3.array(forumValue) })
|
|
6801
7313
|
},
|
|
6802
7314
|
{
|
|
6803
7315
|
name: "search_forums",
|
|
@@ -6813,13 +7325,28 @@ var TOOL_REGISTRATIONS = [
|
|
|
6813
7325
|
maxForums: z3.number().int().min(1).max(50).optional(),
|
|
6814
7326
|
maxDiscussionsPerForum: z3.number().int().min(1).max(100).optional()
|
|
6815
7327
|
}).strict(),
|
|
6816
|
-
output: z3.object({ results:
|
|
7328
|
+
output: z3.object({ results: z3.array(forumSearchValue) })
|
|
6817
7329
|
},
|
|
6818
7330
|
{
|
|
6819
7331
|
name: "get_thread",
|
|
6820
7332
|
description: "Get one Moodle forum discussion and its posts.",
|
|
6821
7333
|
input: z3.object({ discussionId: positiveId }).strict(),
|
|
6822
|
-
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
|
+
})
|
|
6823
7350
|
}
|
|
6824
7351
|
];
|
|
6825
7352
|
var TOOL_CATALOG = TOOL_REGISTRATIONS.map(({ name, description, input, output }) => ({
|
|
@@ -6887,12 +7414,11 @@ function createMoodleMcpServer(gateway, options = {}) {
|
|
|
6887
7414
|
}
|
|
6888
7415
|
if (error instanceof UnsupportedProtocolVersionError) {
|
|
6889
7416
|
return jsonRpcFailure(id, {
|
|
6890
|
-
code: -
|
|
6891
|
-
message:
|
|
7417
|
+
code: -32022,
|
|
7418
|
+
message: "Unsupported protocol version",
|
|
6892
7419
|
data: {
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
supportedVersions: [...error.supportedVersions]
|
|
7420
|
+
supported: [...error.supportedVersions],
|
|
7421
|
+
requested: error.protocolVersion
|
|
6896
7422
|
}
|
|
6897
7423
|
});
|
|
6898
7424
|
}
|
|
@@ -6940,7 +7466,7 @@ async function callTool(gateway, params) {
|
|
|
6940
7466
|
const payload = await runGatewayTool(gateway, name, input);
|
|
6941
7467
|
const structuredContent = registration.output.parse(wrapToolOutput(name, payload));
|
|
6942
7468
|
return {
|
|
6943
|
-
content:
|
|
7469
|
+
content: toolContent(name, payload),
|
|
6944
7470
|
structuredContent,
|
|
6945
7471
|
resultType: "complete",
|
|
6946
7472
|
_meta: RESULT_META
|
|
@@ -7001,6 +7527,8 @@ async function runGatewayTool(gateway, name, input) {
|
|
|
7001
7527
|
});
|
|
7002
7528
|
case "get_thread":
|
|
7003
7529
|
return gateway.getThread({ discussionId: numberValue3(input.discussionId) });
|
|
7530
|
+
case "get_file":
|
|
7531
|
+
return gateway.getFile({ source: fileSource(input.source) });
|
|
7004
7532
|
default:
|
|
7005
7533
|
throw new McpCallError("TOOL_NOT_FOUND", `Unknown Moodle tool: ${name}`);
|
|
7006
7534
|
}
|
|
@@ -7018,8 +7546,33 @@ function wrapToolOutput(name, payload) {
|
|
|
7018
7546
|
search_forums: "results",
|
|
7019
7547
|
get_thread: "thread"
|
|
7020
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
|
+
}
|
|
7021
7559
|
return { [keys[name] ?? "result"]: payload };
|
|
7022
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
|
+
}
|
|
7023
7576
|
function summarizeToolOutput(name, payload) {
|
|
7024
7577
|
if (Array.isArray(payload)) {
|
|
7025
7578
|
const labels = {
|
|
@@ -7051,6 +7604,9 @@ function summarizeToolOutput(name, payload) {
|
|
|
7051
7604
|
if (name === "get_thread" && isRecord9(payload)) {
|
|
7052
7605
|
return `Loaded forum thread ${stringValue4(payload.subject) || numberValue3(payload.id)}.`;
|
|
7053
7606
|
}
|
|
7607
|
+
if (name === "get_file" && isMoodleFile(payload)) {
|
|
7608
|
+
return `Loaded Moodle file ${payload.name} (${payload.bytes} bytes).`;
|
|
7609
|
+
}
|
|
7054
7610
|
return "Moodle request completed.";
|
|
7055
7611
|
}
|
|
7056
7612
|
function mapMoodleError(error) {
|
|
@@ -7086,6 +7642,9 @@ function optionalNumber(value) {
|
|
|
7086
7642
|
function stringValue4(value) {
|
|
7087
7643
|
return typeof value === "string" ? value : "";
|
|
7088
7644
|
}
|
|
7645
|
+
function fileSource(value) {
|
|
7646
|
+
return typeof value === "number" || typeof value === "string" ? value : "";
|
|
7647
|
+
}
|
|
7089
7648
|
function booleanValue2(value) {
|
|
7090
7649
|
return value === true;
|
|
7091
7650
|
}
|
|
@@ -7095,6 +7654,9 @@ function enumValue(value, values) {
|
|
|
7095
7654
|
function isRecord9(value) {
|
|
7096
7655
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7097
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
|
+
}
|
|
7098
7660
|
|
|
7099
7661
|
// src/mcp/stdio.ts
|
|
7100
7662
|
async function serveMoodleMcpStdio(server, options) {
|