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/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 ?? "") || /^HTTP 404\b/.test(message);
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 sectionValue = url?.searchParams.get("section");
988
- if (id === String(courseId) && sectionValue && /^\d+$/.test(sectionValue)) {
989
- const section = Number(sectionValue);
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 ?? "") || /^HTTP 404\b/.test(message);
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
- throw this.errors.api(`HTTP ${response.status} loading ${safeUrl(url)}`);
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 contentType2 = response.headers.get("content-type")?.toLowerCase() ?? "";
2629
- const looksJson = contentType2.includes("application/json") || body.startsWith("{");
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 formatKeyValues([
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 formatColumns([["ID", "Short Name", "Full Name"], ...courses.map((course) => [String(course.id), course.shortname, course.fullname])]);
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
- lines.push(` ${section.name || `Section ${section.section}`}${section.visible ? "" : " (hidden)"}`);
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
- lines.push(` ${activity.name}${activity.visible ? "" : " (hidden)"} (${activity.modname})`);
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 formatColumns([["ID", "Type", "Name"], ...activities.map((activity) => [String(activity.id), activity.modname, activity.name])]);
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
- if (!items.length) {
2752
- return "No upcoming items";
2753
- }
2754
- return formatColumns([
2755
- ["Due", "Course", "Activity", "Type", "Action"],
2756
- ...items.map((item) => [
2757
- item.due_at ? String(item.due_at) : "-",
2758
- item.course_name,
2759
- item.activity_name || item.name,
2760
- item.modname || item.event_type,
2761
- item.actionable ? item.action_name : ""
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 lines = [
2767
- `Notifications: ${alerts.notification_count}`,
2768
- `Unread notifications: ${alerts.unread_notification_count}`,
2769
- `Direct messages: ${alerts.direct_message_count}`,
2770
- `Unread direct messages: ${alerts.unread_direct_message_count}`
2771
- ];
2772
- for (const notification of alerts.notifications) {
2773
- lines.push(`${notification.created_pretty || notification.created_at} ${notification.short_subject || notification.subject}`);
2774
- }
2775
- return lines.join("\n");
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 formatColumns([
2779
- ["Item", "Grade", "Range", "Percent", "Feedback"],
2780
- ...grades.items.map((item) => [item.name, item.grade, item.range, item.percentage, item.feedback])
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 formatKeyValues(rows);
2908
+ return renderKeyValueTable(rows, { title: "Activity" });
2786
2909
  }
2787
2910
  function formatDownloadReceipt(receipt) {
2788
- return formatKeyValues([
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 ? String(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
- const lines = [`Forum ${forumCmid}: Discussions`];
2842
- if (!refs.length) {
2843
- return `${lines[0]}
2844
- No discussions`;
2845
- }
2846
- for (const ref of refs) {
2847
- lines.push([ref.id, ref.subject, ref.group_name, ref.url].filter(Boolean).join(" | "));
2848
- }
2849
- return lines.join("\n");
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
- if (!forums.length) {
2853
- return "Forums\nNo forums";
2854
- }
2855
- return [
2856
- "Forums",
2857
- ...forums.map((forum) => [forum.id, forum.name, forum.course_name, forum.course_id || "", forum.url].filter(Boolean).join(" | "))
2858
- ].join("\n");
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
- if (!hits.length) {
2862
- return "Forum Search\nNo matches";
2863
- }
2864
- return [
2865
- "Forum Search",
2866
- ...hits.map(
2867
- (hit) => [
2868
- hit.course_name,
2869
- hit.forum_name,
2870
- hit.discussion_subject,
2871
- hit.discussion_id || "",
2872
- hit.post_id || "",
2873
- hit.matched_in,
2874
- hit.author_name,
2875
- hit.snippet || hit.discussion_subject,
2876
- hit.url
2877
- ].filter(Boolean).join(" | ")
2878
- )
2879
- ].join("\n");
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 formatKeyValues([
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 formatKeyValues(rows) {
2923
- const present = rows.filter(([, value]) => value);
2924
- const width = Math.max(0, ...present.map(([key]) => key.length));
2925
- return present.map(([key, value]) => `${key.padEnd(width)} ${value}`).join("\n");
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.3";
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 forumValue = url.searchParams.get("id");
3738
- if (!forumValue || !/^\d+$/.test(forumValue)) {
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(forumValue);
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 contentType2 = response.headers.get("content-type")?.toLowerCase() ?? "";
4005
- if (contentType2.includes("text/event-stream")) {
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
- return productionEndpoint ? { ...worker, productionEndpoint } : worker;
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: { MOODLE_ORIGIN: plan.intent.moodleOrigin },
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 objectOutput = z3.looseObject({});
6742
- var listOutput = z3.array(objectOutput);
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: objectOutput })
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({ overview: objectOutput })
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: listOutput })
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({ course: objectOutput })
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: listOutput })
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: objectOutput })
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: objectOutput })
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: listOutput })
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: listOutput })
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: objectOutput })
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: -32602,
6891
- message: error.message,
7417
+ code: -32022,
7418
+ message: "Unsupported protocol version",
6892
7419
  data: {
6893
- type: "UNSUPPORTED_PROTOCOL_VERSION",
6894
- protocolVersion: error.protocolVersion,
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: [{ type: "text", text: summarizeToolOutput(name, payload) }],
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) {