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

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