moodle-cli 0.7.0-alpha.4 → 0.7.0-alpha.5

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