canvas-parent-mcp 2.1.2 → 2.1.3

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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Canvas LMS (Instructure) — courses, grades, assignments, announcements, planner, conversations, files",
10
- "version": "2.1.2"
10
+ "version": "2.1.3"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Canvas LMS",
16
16
  "source": "./",
17
17
  "description": "Canvas LMS (Instructure) MCP server for Claude — student/observer access via natural language",
18
- "version": "2.1.2",
18
+ "version": "2.1.3",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
3
  "displayName": "Canvas LMS",
4
- "version": "2.1.2",
4
+ "version": "2.1.3",
5
5
  "description": "Canvas LMS (Instructure) MCP server for Claude — courses, grades, assignments, announcements, planner, conversations, files",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -29,6 +29,8 @@ Mirrors the read-only parent-portal scope of sibling [`infinitecampus-mcp`](http
29
29
 
30
30
  Tools that the harness will gate as write/IO operations: `canvas_download_file`.
31
31
 
32
+ `canvas_download_file` only fetches `/files/…` URLs on your `CANVAS_BASE_URL` host (https), so your Canvas credential is never sent anywhere else, and it only writes inside a download directory: `CANVAS_OUTPUT_DIR` if set, otherwise `~/Downloads`. A relative `destinationPath` is resolved against that directory.
33
+
32
34
  ## Configuration
33
35
 
34
36
  Set `CANVAS_BASE_URL` plus one of four auth modes. `canvas-parent-mcp` tries them in priority order:
package/dist/bundle.js CHANGED
@@ -4858,7 +4858,7 @@ var require_memoizer = __commonJS({
4858
4858
  var NONE2 = 0;
4859
4859
  var ASSUMED2 = 1;
4860
4860
  var PROVEN2 = 2;
4861
- function isRecursive2(inst, stack, resolve2) {
4861
+ function isRecursive2(inst, stack, resolve5) {
4862
4862
  const cached2 = recursive2.get(inst);
4863
4863
  if (cached2 !== void 0)
4864
4864
  return cached2 ? PROVEN2 : NONE2;
@@ -4868,7 +4868,7 @@ var require_memoizer = __commonJS({
4868
4868
  let result = NONE2;
4869
4869
  const check2 = (child) => {
4870
4870
  if (result !== PROVEN2 && child?._zod) {
4871
- const answer = isRecursive2(child, stack, resolve2);
4871
+ const answer = isRecursive2(child, stack, resolve5);
4872
4872
  if (answer > result)
4873
4873
  result = answer;
4874
4874
  }
@@ -4879,7 +4879,7 @@ var require_memoizer = __commonJS({
4879
4879
  const desc = Object.getOwnPropertyDescriptor(sh, key);
4880
4880
  if (spread && !desc.enumerable)
4881
4881
  continue;
4882
- const child = desc.get ? ASSUMED2 : desc.value?._zod ? isRecursive2(desc.value, stack, resolve2) : NONE2;
4882
+ const child = desc.get ? ASSUMED2 : desc.value?._zod ? isRecursive2(desc.value, stack, resolve5) : NONE2;
4883
4883
  if (child > answer)
4884
4884
  answer = child;
4885
4885
  }
@@ -4943,7 +4943,7 @@ var require_memoizer = __commonJS({
4943
4943
  break;
4944
4944
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
4945
4945
  case "lazy": {
4946
- const inner = def._cachedInner ?? (resolve2 ? inst._zod.innerType : void 0);
4946
+ const inner = def._cachedInner ?? (resolve5 ? inst._zod.innerType : void 0);
4947
4947
  merge2(inner ? isRecursive2(inner, stack, false) : ASSUMED2);
4948
4948
  break;
4949
4949
  }
@@ -26980,7 +26980,7 @@ var require_websocket_server = __commonJS({
26980
26980
  });
26981
26981
 
26982
26982
  // src/index.ts
26983
- import { dirname as dirname3, join as join4 } from "path";
26983
+ import { dirname as dirname4, join as join6 } from "path";
26984
26984
  import { fileURLToPath } from "url";
26985
26985
 
26986
26986
  // node_modules/@chrischall/mcp-utils/dist/cancel/index.js
@@ -31399,14 +31399,14 @@ function inputRequiredRoundsExceededMessage(method, maxRounds) {
31399
31399
  return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`;
31400
31400
  }
31401
31401
  function sleep(ms, signal) {
31402
- return new Promise((resolve2, reject) => {
31402
+ return new Promise((resolve5, reject) => {
31403
31403
  if (signal?.aborted) {
31404
31404
  reject(signal.reason instanceof SdkError ? signal.reason : new SdkError(SdkErrorCode.RequestTimeout, String(signal.reason)));
31405
31405
  return;
31406
31406
  }
31407
31407
  const timer = setTimeout(() => {
31408
31408
  signal?.removeEventListener("abort", onAbort);
31409
- resolve2();
31409
+ resolve5();
31410
31410
  }, ms);
31411
31411
  const onAbort = () => {
31412
31412
  clearTimeout(timer);
@@ -32200,7 +32200,7 @@ var Protocol = class {
32200
32200
  const flowStartedAt = Date.now();
32201
32201
  let onAbort;
32202
32202
  let cleanupMessageId;
32203
- return new Promise((resolve2, reject) => {
32203
+ return new Promise((resolve5, reject) => {
32204
32204
  const earlyReject = (error62) => {
32205
32205
  reject(error62);
32206
32206
  };
@@ -32268,7 +32268,7 @@ var Protocol = class {
32268
32268
  }
32269
32269
  if (decoded.kind === "invalid") return reject(decoded.error);
32270
32270
  if (decoded.kind === "input_required") {
32271
- if (options?.allowInputRequired === true) return resolve2(manualInputRequiredValue(decoded));
32271
+ if (options?.allowInputRequired === true) return resolve5(manualInputRequiredValue(decoded));
32272
32272
  const flow = {
32273
32273
  codec: codec2,
32274
32274
  request,
@@ -32280,11 +32280,11 @@ var Protocol = class {
32280
32280
  params
32281
32281
  }, resultSchema, legOptions)
32282
32282
  };
32283
- return resolve2(this._resolveNonCompleteResult(decoded, flow));
32283
+ return resolve5(this._resolveNonCompleteResult(decoded, flow));
32284
32284
  }
32285
32285
  const result = decoded.result;
32286
32286
  validateStandardSchema(resultSchema, result).then((parseResult) => {
32287
- if (parseResult.success) resolve2(parseResult.data);
32287
+ if (parseResult.success) resolve5(parseResult.data);
32288
32288
  else reject(new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: ${parseResult.error}`));
32289
32289
  }, reject);
32290
32290
  });
@@ -35196,7 +35196,7 @@ var require_compile2 = /* @__PURE__ */ __commonJSMin(((exports) => {
35196
35196
  ref = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, ref);
35197
35197
  const schOrFunc = root.refs[ref];
35198
35198
  if (schOrFunc) return schOrFunc;
35199
- let _sch = resolve2.call(this, root, ref);
35199
+ let _sch = resolve5.call(this, root, ref);
35200
35200
  if (_sch === void 0) {
35201
35201
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
35202
35202
  const { schemaId } = this.opts;
@@ -35222,7 +35222,7 @@ var require_compile2 = /* @__PURE__ */ __commonJSMin(((exports) => {
35222
35222
  function sameSchemaEnv(s1, s2) {
35223
35223
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
35224
35224
  }
35225
- function resolve2(root, ref) {
35225
+ function resolve5(root, ref) {
35226
35226
  let sch;
35227
35227
  while (typeof (sch = this.refs[ref]) == "string") ref = sch;
35228
35228
  return sch || this.schemas[ref] || resolveSchema.call(this, root, ref);
@@ -35672,47 +35672,47 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
35672
35672
  else if (typeof uri === "object") uri = parse3(serialize(uri, options), options);
35673
35673
  return uri;
35674
35674
  }
35675
- function resolve2(baseURI, relativeURI, options) {
35675
+ function resolve5(baseURI, relativeURI, options) {
35676
35676
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
35677
35677
  const resolved2 = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
35678
35678
  schemelessOptions.skipEscape = true;
35679
35679
  return serialize(resolved2, schemelessOptions);
35680
35680
  }
35681
- function resolveComponent(base, relative, options, skipNormalization) {
35681
+ function resolveComponent(base, relative2, options, skipNormalization) {
35682
35682
  const target = {};
35683
35683
  if (!skipNormalization) {
35684
35684
  base = parse3(serialize(base, options), options);
35685
- relative = parse3(serialize(relative, options), options);
35685
+ relative2 = parse3(serialize(relative2, options), options);
35686
35686
  }
35687
35687
  options = options || {};
35688
- if (!options.tolerant && relative.scheme) {
35689
- target.scheme = relative.scheme;
35690
- target.userinfo = relative.userinfo;
35691
- target.host = relative.host;
35692
- target.port = relative.port;
35693
- target.path = removeDotSegments(relative.path || "");
35694
- target.query = relative.query;
35688
+ if (!options.tolerant && relative2.scheme) {
35689
+ target.scheme = relative2.scheme;
35690
+ target.userinfo = relative2.userinfo;
35691
+ target.host = relative2.host;
35692
+ target.port = relative2.port;
35693
+ target.path = removeDotSegments(relative2.path || "");
35694
+ target.query = relative2.query;
35695
35695
  } else {
35696
- if (relative.userinfo !== void 0 || relative.host !== void 0 || relative.port !== void 0) {
35697
- target.userinfo = relative.userinfo;
35698
- target.host = relative.host;
35699
- target.port = relative.port;
35700
- target.path = removeDotSegments(relative.path || "");
35701
- target.query = relative.query;
35696
+ if (relative2.userinfo !== void 0 || relative2.host !== void 0 || relative2.port !== void 0) {
35697
+ target.userinfo = relative2.userinfo;
35698
+ target.host = relative2.host;
35699
+ target.port = relative2.port;
35700
+ target.path = removeDotSegments(relative2.path || "");
35701
+ target.query = relative2.query;
35702
35702
  } else {
35703
- if (!relative.path) {
35703
+ if (!relative2.path) {
35704
35704
  target.path = base.path;
35705
- if (relative.query !== void 0) target.query = relative.query;
35705
+ if (relative2.query !== void 0) target.query = relative2.query;
35706
35706
  else target.query = base.query;
35707
35707
  } else {
35708
- if (relative.path[0] === "/") target.path = removeDotSegments(relative.path);
35708
+ if (relative2.path[0] === "/") target.path = removeDotSegments(relative2.path);
35709
35709
  else {
35710
- if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) target.path = "/" + relative.path;
35711
- else if (!base.path) target.path = relative.path;
35712
- else target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative.path;
35710
+ if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) target.path = "/" + relative2.path;
35711
+ else if (!base.path) target.path = relative2.path;
35712
+ else target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative2.path;
35713
35713
  target.path = removeDotSegments(target.path);
35714
35714
  }
35715
- target.query = relative.query;
35715
+ target.query = relative2.query;
35716
35716
  }
35717
35717
  target.userinfo = base.userinfo;
35718
35718
  target.host = base.host;
@@ -35720,7 +35720,7 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
35720
35720
  }
35721
35721
  target.scheme = base.scheme;
35722
35722
  }
35723
- target.fragment = relative.fragment;
35723
+ target.fragment = relative2.fragment;
35724
35724
  return target;
35725
35725
  }
35726
35726
  function equal(uriA, uriB, options) {
@@ -35846,7 +35846,7 @@ var require_fast_uri = /* @__PURE__ */ __commonJSMin(((exports, module) => {
35846
35846
  const fastUri = {
35847
35847
  SCHEMES,
35848
35848
  normalize,
35849
- resolve: resolve2,
35849
+ resolve: resolve5,
35850
35850
  resolveComponent,
35851
35851
  equal,
35852
35852
  serialize,
@@ -41219,7 +41219,7 @@ var StdioServerTransport = class {
41219
41219
  }
41220
41220
  send(message) {
41221
41221
  if (this._closed) return Promise.reject(/* @__PURE__ */ new Error("StdioServerTransport is closed"));
41222
- return new Promise((resolve2, reject) => {
41222
+ return new Promise((resolve5, reject) => {
41223
41223
  const json2 = serializeMessage(message);
41224
41224
  let settled = false;
41225
41225
  const onError = (error62) => {
@@ -41234,14 +41234,14 @@ var StdioServerTransport = class {
41234
41234
  settled = true;
41235
41235
  this._stdout.off("error", onError);
41236
41236
  this._stdout.off("drain", onDrain);
41237
- resolve2();
41237
+ resolve5();
41238
41238
  };
41239
41239
  this._stdout.once("error", onError);
41240
41240
  if (this._stdout.write(json2)) {
41241
41241
  if (settled) return;
41242
41242
  settled = true;
41243
41243
  this._stdout.off("error", onError);
41244
- resolve2();
41244
+ resolve5();
41245
41245
  } else if (!settled) this._stdout.once("drain", onDrain);
41246
41246
  });
41247
41247
  }
@@ -41295,14 +41295,14 @@ var StdioConnectionChannel = class {
41295
41295
  */
41296
41296
  async whenRequestsAnswered(timeoutMs) {
41297
41297
  if (this._closed || this._pendingRequests.size === 0) return true;
41298
- return await new Promise((resolve2) => {
41298
+ return await new Promise((resolve5) => {
41299
41299
  const waiter = () => {
41300
41300
  clearTimeout(timer);
41301
- resolve2(true);
41301
+ resolve5(true);
41302
41302
  };
41303
41303
  const timer = setTimeout(() => {
41304
41304
  this._drainWaiters = this._drainWaiters.filter((pending) => pending !== waiter);
41305
- resolve2(false);
41305
+ resolve5(false);
41306
41306
  }, timeoutMs);
41307
41307
  this._drainWaiters.push(waiter);
41308
41308
  });
@@ -46738,7 +46738,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
46738
46738
  var NONE = 0;
46739
46739
  var ASSUMED = 1;
46740
46740
  var PROVEN = 2;
46741
- function isRecursive(inst, stack, resolve2) {
46741
+ function isRecursive(inst, stack, resolve5) {
46742
46742
  const cached2 = recursive.get(inst);
46743
46743
  if (cached2 !== void 0)
46744
46744
  return cached2 ? PROVEN : NONE;
@@ -46748,7 +46748,7 @@ function isRecursive(inst, stack, resolve2) {
46748
46748
  let result = NONE;
46749
46749
  const check2 = (child) => {
46750
46750
  if (result !== PROVEN && child?._zod) {
46751
- const answer = isRecursive(child, stack, resolve2);
46751
+ const answer = isRecursive(child, stack, resolve5);
46752
46752
  if (answer > result)
46753
46753
  result = answer;
46754
46754
  }
@@ -46759,7 +46759,7 @@ function isRecursive(inst, stack, resolve2) {
46759
46759
  const desc = Object.getOwnPropertyDescriptor(sh, key);
46760
46760
  if (spread && !desc.enumerable)
46761
46761
  continue;
46762
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve2) : NONE;
46762
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve5) : NONE;
46763
46763
  if (child > answer)
46764
46764
  answer = child;
46765
46765
  }
@@ -46823,7 +46823,7 @@ function isRecursive(inst, stack, resolve2) {
46823
46823
  break;
46824
46824
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
46825
46825
  case "lazy": {
46826
- const inner = def._cachedInner ?? (resolve2 ? inst._zod.innerType : void 0);
46826
+ const inner = def._cachedInner ?? (resolve5 ? inst._zod.innerType : void 0);
46827
46827
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
46828
46828
  break;
46829
46829
  }
@@ -61639,6 +61639,8 @@ function runMcp(opts) {
61639
61639
  }
61640
61640
 
61641
61641
  // node_modules/@chrischall/mcp-utils/dist/config/index.js
61642
+ import { homedir } from "node:os";
61643
+ import { isAbsolute, join, resolve } from "node:path";
61642
61644
  var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
61643
61645
  function readEnvVar(key, opts = {}) {
61644
61646
  const env = opts.env ?? process.env;
@@ -61665,6 +61667,15 @@ function parseBoolEnv(key, opts = {}) {
61665
61667
  return false;
61666
61668
  return fallback;
61667
61669
  }
61670
+ function expandPath(p) {
61671
+ let expanded = p;
61672
+ if (p === "~") {
61673
+ expanded = homedir();
61674
+ } else if (p.startsWith("~/")) {
61675
+ expanded = join(homedir(), p.slice(2));
61676
+ }
61677
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
61678
+ }
61668
61679
  async function loadDotenvSafely(opts = {}) {
61669
61680
  try {
61670
61681
  const mod = await import(
@@ -61682,6 +61693,41 @@ async function loadDotenvSafely(opts = {}) {
61682
61693
  }
61683
61694
  }
61684
61695
 
61696
+ // node_modules/@chrischall/mcp-utils/dist/fs/confine.js
61697
+ import { existsSync, realpathSync } from "node:fs";
61698
+ import { dirname, isAbsolute as isAbsolute2, relative, resolve as resolve2, sep } from "node:path";
61699
+ function isInside(root, target) {
61700
+ const rel = relative(root, target);
61701
+ return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute2(rel);
61702
+ }
61703
+ function realPathAllowingMissing(p) {
61704
+ const abs = resolve2(p);
61705
+ let existing = abs;
61706
+ const tail = [];
61707
+ while (!existsSync(existing)) {
61708
+ const parent = dirname(existing);
61709
+ if (parent === existing)
61710
+ break;
61711
+ tail.unshift(existing.slice(parent.length).replace(/^[/\\]+/, ""));
61712
+ existing = parent;
61713
+ }
61714
+ let real;
61715
+ try {
61716
+ real = realpathSync(existing);
61717
+ } catch {
61718
+ real = existing;
61719
+ }
61720
+ return tail.length > 0 ? resolve2(real, ...tail) : real;
61721
+ }
61722
+ function assertPathWithinRoots(path, roots) {
61723
+ const target = realPathAllowingMissing(expandPath(path));
61724
+ for (const root of roots) {
61725
+ if (isInside(realPathAllowingMissing(expandPath(root)), target))
61726
+ return target;
61727
+ }
61728
+ throw new Error(`Path is outside the allowed directories: ${path}`);
61729
+ }
61730
+
61685
61731
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
61686
61732
  function buildQueryString(params) {
61687
61733
  const parts = [];
@@ -63495,7 +63541,7 @@ var DEFAULT_BIND_TIMEOUT_MS = 5e3;
63495
63541
  async function electRole(opts) {
63496
63542
  const server = createServer();
63497
63543
  const bindTimeoutMs = opts.bindTimeoutMs ?? DEFAULT_BIND_TIMEOUT_MS;
63498
- return new Promise((resolve2, reject) => {
63544
+ return new Promise((resolve5, reject) => {
63499
63545
  let timer;
63500
63546
  const clearBindTimer = () => {
63501
63547
  if (timer) {
@@ -63511,7 +63557,7 @@ async function electRole(opts) {
63511
63557
  server.close();
63512
63558
  } catch {
63513
63559
  }
63514
- resolve2({ role: "peer" });
63560
+ resolve5({ role: "peer" });
63515
63561
  } else {
63516
63562
  reject(e);
63517
63563
  }
@@ -63519,7 +63565,7 @@ async function electRole(opts) {
63519
63565
  const onListening = () => {
63520
63566
  clearBindTimer();
63521
63567
  server.removeListener("error", onError);
63522
- resolve2({ role: "host", server });
63568
+ resolve5({ role: "host", server });
63523
63569
  };
63524
63570
  server.once("error", onError);
63525
63571
  server.once("listening", onListening);
@@ -63798,13 +63844,13 @@ async function awaitSessionReady(ready, opts) {
63798
63844
  // node_modules/@fetchproxy/server/dist/extension-trust.js
63799
63845
  import { readFile as readFile2, writeFile as writeFile2, rename as rename2, unlink as unlink2, mkdir as mkdir2, chmod as chmod2, rm as rm2 } from "node:fs/promises";
63800
63846
  import { randomBytes as randomBytes2 } from "node:crypto";
63801
- import { isAbsolute as isAbsolute2, join as join2 } from "node:path";
63847
+ import { isAbsolute as isAbsolute4, join as join3 } from "node:path";
63802
63848
 
63803
63849
  // node_modules/@fetchproxy/server/dist/identity.js
63804
63850
  import { readFile, readdir, writeFile, mkdir, chmod, rename, rm, unlink, link, lstat } from "node:fs/promises";
63805
63851
  import { randomBytes } from "node:crypto";
63806
- import { isAbsolute, join } from "node:path";
63807
- import { homedir } from "node:os";
63852
+ import { isAbsolute as isAbsolute3, join as join2 } from "node:path";
63853
+ import { homedir as homedir2 } from "node:os";
63808
63854
  var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
63809
63855
  var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
63810
63856
  function envIdentityDir() {
@@ -63812,12 +63858,12 @@ function envIdentityDir() {
63812
63858
  if (raw === void 0)
63813
63859
  return void 0;
63814
63860
  const dir = raw.trim();
63815
- if (dir === "" || !isAbsolute(dir))
63861
+ if (dir === "" || !isAbsolute3(dir))
63816
63862
  return void 0;
63817
63863
  return dir;
63818
63864
  }
63819
63865
  function defaultIdentityDir() {
63820
- return envIdentityDir() ?? join(homedir(), ".fetchproxy", "identity");
63866
+ return envIdentityDir() ?? join2(homedir2(), ".fetchproxy", "identity");
63821
63867
  }
63822
63868
  function safeIdentityFileBase(serverName) {
63823
63869
  if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
@@ -63826,7 +63872,7 @@ function safeIdentityFileBase(serverName) {
63826
63872
  return serverName.replace(/\//g, "_");
63827
63873
  }
63828
63874
  function identityFilePath(dir, serverName) {
63829
- return join(dir, `${safeIdentityFileBase(serverName)}.json`);
63875
+ return join2(dir, `${safeIdentityFileBase(serverName)}.json`);
63830
63876
  }
63831
63877
  async function generateIdentity() {
63832
63878
  const x = await generateX25519();
@@ -63880,7 +63926,7 @@ async function sweepStagingFiles(dir, serverName, opts = {}) {
63880
63926
  }
63881
63927
  const cutoff = opts.olderThanMs === void 0 ? null : Date.now() - opts.olderThanMs;
63882
63928
  await Promise.all(names.filter((name) => staging.test(name)).map(async (name) => {
63883
- const path = join(dir, name);
63929
+ const path = join2(dir, name);
63884
63930
  if (cutoff !== null) {
63885
63931
  const st = await lstat(path).catch(() => null);
63886
63932
  if (st === null || st.mtimeMs > cutoff)
@@ -63961,7 +64007,7 @@ function envTrustDir(env = process.env) {
63961
64007
  if (raw === void 0)
63962
64008
  return void 0;
63963
64009
  const dir = raw.trim();
63964
- if (dir !== "" && isAbsolute2(dir))
64010
+ if (dir !== "" && isAbsolute4(dir))
63965
64011
  return dir;
63966
64012
  console.warn(`[fetchproxy] ignoring ${TRUST_DIR_ENV}=${JSON.stringify(raw)}: it is not an absolute path. The extension pin will be written beside the identity instead, which is not where you pointed it \u2014 if that directory is read-only, no pin is ever kept.`);
63967
64013
  return void 0;
@@ -64009,13 +64055,13 @@ function decideExtensionTrust(args) {
64009
64055
  };
64010
64056
  }
64011
64057
  function extensionTrustPath(serverName, dir = defaultTrustDir()) {
64012
- return join2(dir, `${safeIdentityFileBase(serverName)}.extension-trust.json`);
64058
+ return join3(dir, `${safeIdentityFileBase(serverName)}.extension-trust.json`);
64013
64059
  }
64014
64060
  function extensionTrustPathHint(serverName) {
64015
64061
  try {
64016
64062
  return extensionTrustPath(serverName);
64017
64063
  } catch {
64018
- return join2(defaultTrustDir(), "<server-name>.extension-trust.json");
64064
+ return join3(defaultTrustDir(), "<server-name>.extension-trust.json");
64019
64065
  }
64020
64066
  }
64021
64067
  function isPin(x) {
@@ -64168,8 +64214,8 @@ async function startHost(opts) {
64168
64214
  let rejectOwnSession;
64169
64215
  let ownSessionReady;
64170
64216
  function resetSessionPromise() {
64171
- ownSessionReady = new Promise((resolve2, reject) => {
64172
- resolveOwnSession = resolve2;
64217
+ ownSessionReady = new Promise((resolve5, reject) => {
64218
+ resolveOwnSession = resolve5;
64173
64219
  rejectOwnSession = reject;
64174
64220
  });
64175
64221
  ownSessionReady.catch(() => {
@@ -64571,7 +64617,7 @@ async function startHost(opts) {
64571
64617
  });
64572
64618
  });
64573
64619
  return {
64574
- close: () => new Promise((resolve2) => {
64620
+ close: () => new Promise((resolve5) => {
64575
64621
  for (const client of wss.clients) {
64576
64622
  try {
64577
64623
  client.terminate();
@@ -64579,7 +64625,7 @@ async function startHost(opts) {
64579
64625
  }
64580
64626
  }
64581
64627
  wss.close(() => {
64582
- opts.httpServer.close(() => resolve2());
64628
+ opts.httpServer.close(() => resolve5());
64583
64629
  });
64584
64630
  }),
64585
64631
  sendOwnInner: async (inner) => {
@@ -64619,11 +64665,11 @@ async function startPeer(opts) {
64619
64665
  maxPayload: opts.maxPayloadBytes ?? MAX_PAYLOAD_BYTES
64620
64666
  });
64621
64667
  const dialTimeoutMs = opts.dialTimeoutMs ?? PEER_DIAL_TIMEOUT_MS;
64622
- await new Promise((resolve2, reject) => {
64668
+ await new Promise((resolve5, reject) => {
64623
64669
  let timer;
64624
64670
  const onOpen = () => {
64625
64671
  cleanup();
64626
- resolve2();
64672
+ resolve5();
64627
64673
  };
64628
64674
  const onError = (e) => {
64629
64675
  cleanup();
@@ -64711,10 +64757,10 @@ async function startPeer(opts) {
64711
64757
  let sessionPromiseSettled = false;
64712
64758
  function resetSessionPromise() {
64713
64759
  sessionPromiseSettled = false;
64714
- sessionPromise = new Promise((resolve2, reject) => {
64760
+ sessionPromise = new Promise((resolve5, reject) => {
64715
64761
  resolveFirstReady = (st) => {
64716
64762
  sessionPromiseSettled = true;
64717
- resolve2(st);
64763
+ resolve5(st);
64718
64764
  };
64719
64765
  rejectFirstReady = (e) => {
64720
64766
  sessionPromiseSettled = true;
@@ -65864,8 +65910,8 @@ var FetchproxyServer = class {
65864
65910
  async _fetchOnceWithTimeout(init, fetchOpts = {}) {
65865
65911
  const id = this.nextRequestId++;
65866
65912
  const inner = { type: "request", id, op: "fetch", init };
65867
- const pending = this.guardPending(new Promise((resolve2) => {
65868
- this.pending.set(id, resolve2);
65913
+ const pending = this.guardPending(new Promise((resolve5) => {
65914
+ this.pending.set(id, resolve5);
65869
65915
  }));
65870
65916
  try {
65871
65917
  await this.sendInnerFrame(inner, {
@@ -65888,12 +65934,12 @@ var FetchproxyServer = class {
65888
65934
  try {
65889
65935
  return await Promise.race([
65890
65936
  pending,
65891
- new Promise((resolve2) => {
65937
+ new Promise((resolve5) => {
65892
65938
  timer = setTimeout(() => {
65893
65939
  this.pending.delete(id);
65894
65940
  const elapsedMs = Date.now() - start;
65895
65941
  const error62 = `fetchproxy: ${init.url} did not respond within ${timeoutMs}ms`;
65896
- resolve2({
65942
+ resolve5({
65897
65943
  ok: false,
65898
65944
  error: error62,
65899
65945
  kind: "timeout",
@@ -66037,9 +66083,9 @@ var FetchproxyServer = class {
66037
66083
  if (opts.subdomain !== void 0)
66038
66084
  assertSubdomainLabel(opts.subdomain);
66039
66085
  const baseDomain = this.resolveBaseDomain(opts.domain);
66040
- const isAbsolute3 = path.startsWith("http://") || path.startsWith("https://");
66086
+ const isAbsolute6 = path.startsWith("http://") || path.startsWith("https://");
66041
66087
  let host;
66042
- if (isAbsolute3) {
66088
+ if (isAbsolute6) {
66043
66089
  try {
66044
66090
  host = new URL(path).host;
66045
66091
  } catch {
@@ -66048,7 +66094,7 @@ var FetchproxyServer = class {
66048
66094
  } else {
66049
66095
  host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
66050
66096
  }
66051
- const url4 = isAbsolute3 ? path : `https://${host}${path}`;
66097
+ const url4 = isAbsolute6 ? path : `https://${host}${path}`;
66052
66098
  assertUrlInDomains("request url", url4, this.opts.domains);
66053
66099
  let tabUrl = `https://${host}/`;
66054
66100
  if (opts.viaTab !== void 0) {
@@ -66313,8 +66359,8 @@ var FetchproxyServer = class {
66313
66359
  const tabUrl = `https://${host}/`;
66314
66360
  inner = { type: "request", id, op: "read_cookies", init: { tabUrl } };
66315
66361
  }
66316
- const pending = this.guardPending(new Promise((resolve2) => {
66317
- this.pendingReadCookies.set(id, resolve2);
66362
+ const pending = this.guardPending(new Promise((resolve5) => {
66363
+ this.pendingReadCookies.set(id, resolve5);
66318
66364
  }));
66319
66365
  await this.sendInnerFrame(inner);
66320
66366
  const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
@@ -66372,8 +66418,8 @@ var FetchproxyServer = class {
66372
66418
  ...cookiePath !== void 0 ? { path: cookiePath } : {}
66373
66419
  }
66374
66420
  };
66375
- const pending = this.guardPending(new Promise((resolve2, reject) => {
66376
- this.pendingWriteCookies.set(id, { resolve: resolve2, reject });
66421
+ const pending = this.guardPending(new Promise((resolve5, reject) => {
66422
+ this.pendingWriteCookies.set(id, { resolve: resolve5, reject });
66377
66423
  }));
66378
66424
  await this.sendInnerFrame(inner);
66379
66425
  return this._withVerbTimeout(
@@ -66443,8 +66489,8 @@ var FetchproxyServer = class {
66443
66489
  ...opts.pointers ? { pointers: { ...opts.pointers } } : {}
66444
66490
  }
66445
66491
  };
66446
- const pending = this.guardPending(new Promise((resolve2, reject) => {
66447
- this.pendingStorage.set(id, { resolve: resolve2, reject });
66492
+ const pending = this.guardPending(new Promise((resolve5, reject) => {
66493
+ this.pendingStorage.set(id, { resolve: resolve5, reject });
66448
66494
  }));
66449
66495
  await this.sendInnerFrame(inner);
66450
66496
  return this._withVerbTimeout(pending, this.pendingStorage, id, `https://${host}`);
@@ -66502,8 +66548,8 @@ var FetchproxyServer = class {
66502
66548
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
66503
66549
  }
66504
66550
  };
66505
- const pending = this.guardPending(new Promise((resolve2, reject) => {
66506
- this.pendingCapture.set(id, { resolve: resolve2, reject });
66551
+ const pending = this.guardPending(new Promise((resolve5, reject) => {
66552
+ this.pendingCapture.set(id, { resolve: resolve5, reject });
66507
66553
  }));
66508
66554
  await this.sendInnerFrame(inner);
66509
66555
  return this._withVerbTimeout(
@@ -66550,8 +66596,8 @@ var FetchproxyServer = class {
66550
66596
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
66551
66597
  }
66552
66598
  };
66553
- const pending = this.guardPending(new Promise((resolve2, reject) => {
66554
- this.pendingRedirect.set(id, { resolve: resolve2, reject });
66599
+ const pending = this.guardPending(new Promise((resolve5, reject) => {
66600
+ this.pendingRedirect.set(id, { resolve: resolve5, reject });
66555
66601
  }));
66556
66602
  await this.sendInnerFrame(inner);
66557
66603
  return this._withVerbTimeout(
@@ -66646,8 +66692,8 @@ var FetchproxyServer = class {
66646
66692
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
66647
66693
  }
66648
66694
  };
66649
- const pending = this.guardPending(new Promise((resolve2, reject) => {
66650
- this.pendingDownload.set(id, { resolve: resolve2, reject });
66695
+ const pending = this.guardPending(new Promise((resolve5, reject) => {
66696
+ this.pendingDownload.set(id, { resolve: resolve5, reject });
66651
66697
  }));
66652
66698
  await this.sendInnerFrame(inner);
66653
66699
  return this._withVerbTimeout(
@@ -66707,8 +66753,8 @@ var FetchproxyServer = class {
66707
66753
  keys: [...opts.keys]
66708
66754
  }
66709
66755
  };
66710
- const pending = this.guardPending(new Promise((resolve2, reject) => {
66711
- this.pendingIdb.set(id, { resolve: resolve2, reject });
66756
+ const pending = this.guardPending(new Promise((resolve5, reject) => {
66757
+ this.pendingIdb.set(id, { resolve: resolve5, reject });
66712
66758
  }));
66713
66759
  await this.sendInnerFrame(inner);
66714
66760
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
@@ -66747,8 +66793,8 @@ var FetchproxyServer = class {
66747
66793
  op: "read_dom",
66748
66794
  init: { origin, names: [...opts.names] }
66749
66795
  };
66750
- const pending = this.guardPending(new Promise((resolve2, reject) => {
66751
- this.pendingStorage.set(id, { resolve: resolve2, reject });
66796
+ const pending = this.guardPending(new Promise((resolve5, reject) => {
66797
+ this.pendingStorage.set(id, { resolve: resolve5, reject });
66752
66798
  }));
66753
66799
  await this.sendInnerFrame(inner);
66754
66800
  return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
@@ -66793,8 +66839,8 @@ var FetchproxyServer = class {
66793
66839
  op: "read_dom_list",
66794
66840
  init: { origin, name: opts.name }
66795
66841
  };
66796
- const pending = this.guardPending(new Promise((resolve2, reject) => {
66797
- this.pendingDomList.set(id, { resolve: resolve2, reject });
66842
+ const pending = this.guardPending(new Promise((resolve5, reject) => {
66843
+ this.pendingDomList.set(id, { resolve: resolve5, reject });
66798
66844
  }));
66799
66845
  await this.sendInnerFrame(inner);
66800
66846
  return this._withVerbTimeout(pending, this.pendingDomList, id, origin);
@@ -66839,8 +66885,8 @@ var FetchproxyServer = class {
66839
66885
  ...opts.tabUrl !== void 0 ? { tabUrl: opts.tabUrl } : {}
66840
66886
  }
66841
66887
  };
66842
- const pending = this.guardPending(new Promise((resolve2, reject) => {
66843
- this.pendingGraphql.set(id, { resolve: resolve2, reject });
66888
+ const pending = this.guardPending(new Promise((resolve5, reject) => {
66889
+ this.pendingGraphql.set(id, { resolve: resolve5, reject });
66844
66890
  }));
66845
66891
  const retrySafe = opts.retryOnTimeout === true;
66846
66892
  await this.sendInnerFrame(inner, { resendable: retrySafe });
@@ -67535,7 +67581,7 @@ function loadAccount(env = process.env) {
67535
67581
  // package.json
67536
67582
  var package_default = {
67537
67583
  name: "canvas-parent-mcp",
67538
- version: "2.1.2",
67584
+ version: "2.1.3",
67539
67585
  mcpName: "io.github.chrischall/canvas-parent-mcp",
67540
67586
  description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
67541
67587
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -67844,9 +67890,9 @@ function registerCredentialHealthcheckTool(args) {
67844
67890
  import { writeFile as writeFile3, stat } from "fs/promises";
67845
67891
 
67846
67892
  // node_modules/@chrischall/mcp-utils/dist/session/index.js
67847
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
67848
- import { dirname, join as join3, resolve } from "node:path";
67849
- import { homedir as homedir2 } from "node:os";
67893
+ import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
67894
+ import { dirname as dirname2, join as join4, resolve as resolve3 } from "node:path";
67895
+ import { homedir as homedir3 } from "node:os";
67850
67896
  import { randomBytes as randomBytes3, createHmac } from "node:crypto";
67851
67897
  function bindingDigest(secret, salt) {
67852
67898
  return createHmac("sha256", salt).update(secret).digest("hex").slice(0, 32);
@@ -67868,7 +67914,7 @@ function createFileStatePersistence(opts) {
67868
67914
  const secret = opts.boundTo;
67869
67915
  return {
67870
67916
  load() {
67871
- if (!existsSync(filePath))
67917
+ if (!existsSync2(filePath))
67872
67918
  return null;
67873
67919
  try {
67874
67920
  const raw = JSON.parse(readFileSync(filePath, "utf8"));
@@ -67898,9 +67944,9 @@ function createFileStatePersistence(opts) {
67898
67944
  }
67899
67945
  },
67900
67946
  save(state) {
67901
- const dir = dirname(filePath);
67947
+ const dir = dirname2(filePath);
67902
67948
  const tmp = `${filePath}.tmp-${randomBytes3(6).toString("hex")}`;
67903
- const dirExisted = existsSync(dir);
67949
+ const dirExisted = existsSync2(dir);
67904
67950
  try {
67905
67951
  mkdirSync(dir, { recursive: true, mode: 448 });
67906
67952
  if (!dirExisted)
@@ -67917,7 +67963,7 @@ function createFileStatePersistence(opts) {
67917
67963
  chmodSync(filePath, 384);
67918
67964
  } catch (err) {
67919
67965
  try {
67920
- if (existsSync(tmp))
67966
+ if (existsSync2(tmp))
67921
67967
  unlinkSync(tmp);
67922
67968
  } catch {
67923
67969
  }
@@ -67926,7 +67972,7 @@ function createFileStatePersistence(opts) {
67926
67972
  },
67927
67973
  clear() {
67928
67974
  try {
67929
- if (existsSync(filePath))
67975
+ if (existsSync2(filePath))
67930
67976
  unlinkSync(filePath);
67931
67977
  } catch {
67932
67978
  }
@@ -67934,11 +67980,11 @@ function createFileStatePersistence(opts) {
67934
67980
  };
67935
67981
  }
67936
67982
  function homeOf(env) {
67937
- return readEnvVar("HOME", { env }) ?? homedir2();
67983
+ return readEnvVar("HOME", { env }) ?? homedir3();
67938
67984
  }
67939
67985
  function resolveStateDir(opts = {}) {
67940
67986
  const env = opts.env;
67941
- return join3(readEnvVar("MCP_DATA_DIR", { env }) ?? homeOf(env), opts.subdir ?? "");
67987
+ return join4(readEnvVar("MCP_DATA_DIR", { env }) ?? homeOf(env), opts.subdir ?? "");
67942
67988
  }
67943
67989
  function resolveStateFile(opts) {
67944
67990
  const override = opts.envVar !== void 0 ? readEnvVar(opts.envVar, { env: opts.env }) : void 0;
@@ -67946,13 +67992,13 @@ function resolveStateFile(opts) {
67946
67992
  if (override === "~")
67947
67993
  return homeOf(opts.env);
67948
67994
  if (override.startsWith("~/"))
67949
- return join3(homeOf(opts.env), override.slice(2));
67950
- return resolve(override);
67995
+ return join4(homeOf(opts.env), override.slice(2));
67996
+ return resolve3(override);
67951
67997
  }
67952
67998
  const dirOpts = { env: opts.env };
67953
67999
  if (opts.subdir !== void 0)
67954
68000
  dirOpts.subdir = opts.subdir;
67955
- return join3(resolveStateDir(dirOpts), opts.fileName);
68001
+ return join4(resolveStateDir(dirOpts), opts.fileName);
67956
68002
  }
67957
68003
  var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
67958
68004
  function isPersistedCookieSession(raw) {
@@ -68242,7 +68288,8 @@ function reportCacheWriteFailure(err) {
68242
68288
  }
68243
68289
 
68244
68290
  // src/client.ts
68245
- import { dirname as dirname2 } from "path";
68291
+ import { dirname as dirname3, isAbsolute as isAbsolute5, join as join5, resolve as resolve4 } from "path";
68292
+ import { homedir as homedir4 } from "os";
68246
68293
 
68247
68294
  // src/session-login.ts
68248
68295
  var USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36";
@@ -68340,6 +68387,8 @@ var CanvasClient = class {
68340
68387
  * what keeps the path alive once the browser cookie lapses.
68341
68388
  */
68342
68389
  refreshSession;
68390
+ /** Explicit download root; when unset, resolved per call (see {@link downloadRoot}). */
68391
+ outputDir;
68343
68392
  auth;
68344
68393
  /** Lazily-built shared refresh_token exchanger (oauth mode only). */
68345
68394
  oauthRefresh = null;
@@ -68355,6 +68404,7 @@ var CanvasClient = class {
68355
68404
  */
68356
68405
  constructor(account, opts = {}) {
68357
68406
  this.account = account;
68407
+ this.outputDir = opts.outputDir;
68358
68408
  this.sessionLoginFn = opts.sessionLogin ?? sessionLogin;
68359
68409
  this.refreshSession = opts.refreshSession ?? null;
68360
68410
  this.auth = new CookieSessionManager({
@@ -68396,33 +68446,84 @@ var CanvasClient = class {
68396
68446
  }
68397
68447
  return out;
68398
68448
  }
68449
+ /**
68450
+ * Download a Canvas file to disk.
68451
+ *
68452
+ * Both inputs arrive from tool arguments, which a prompt injection in
68453
+ * classmate/teacher-authored Canvas content can steer, so both are pinned
68454
+ * BEFORE any credential is attached:
68455
+ * - `path` must resolve to the configured Canvas origin (https, same host —
68456
+ * the `https://canvas@evil/` userinfo trick parses to a foreign host and is
68457
+ * refused) and name a `/files/<id>` resource. Only the initial request is
68458
+ * pinned: Canvas legitimately redirects to its file store, and fetch
68459
+ * strips Authorization/Cookie on a cross-origin redirect.
68460
+ * - `destinationPath` must lie inside the download root (see
68461
+ * {@link downloadRoot}), checked through symlinks; a relative path is
68462
+ * resolved against that root.
68463
+ */
68399
68464
  async download(path, destinationPath, opts = {}) {
68465
+ const url4 = this.pinDownloadUrl(path);
68466
+ const dest = this.confineDestination(destinationPath);
68400
68467
  let destStat = null;
68401
68468
  try {
68402
- destStat = await stat(destinationPath);
68469
+ destStat = await stat(dest);
68403
68470
  } catch {
68404
68471
  }
68405
68472
  if (destStat?.isDirectory()) throw new InvalidPathError(destinationPath);
68406
68473
  if (destStat && !opts.overwrite) throw new FileExistsError(destinationPath);
68407
- const parent = dirname2(destinationPath);
68474
+ const parent = dirname3(dest);
68408
68475
  try {
68409
68476
  await stat(parent);
68410
68477
  } catch {
68411
68478
  throw new ParentDirectoryMissingError(parent);
68412
68479
  }
68413
- const url4 = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
68414
68480
  const res = await this.authedFetch(url4, {});
68415
68481
  if (res.status === 401) throw new TokenExpiredError(this.account.mode);
68416
68482
  if (res.status === 404) throw new Error(`Canvas download 404 for ${path}`);
68417
68483
  if (!res.ok) throw new Error(`Canvas download ${res.status} for ${path}`);
68418
68484
  const buf = new Uint8Array(await res.arrayBuffer());
68419
- await writeFile3(destinationPath, buf);
68485
+ await writeFile3(dest, buf);
68420
68486
  return {
68421
- path: destinationPath,
68487
+ path: dest,
68422
68488
  bytes: buf.byteLength,
68423
68489
  contentType: res.headers.get("content-type") ?? "application/octet-stream"
68424
68490
  };
68425
68491
  }
68492
+ /**
68493
+ * The directory downloads are confined to: the constructor's `outputDir`,
68494
+ * else `CANVAS_OUTPUT_DIR`, else `~/Downloads`. Read per call so an env
68495
+ * change needs no client rebuild.
68496
+ */
68497
+ downloadRoot() {
68498
+ return expandPath(this.outputDir ?? readEnvVar("CANVAS_OUTPUT_DIR") ?? join5(homedir4(), "Downloads"));
68499
+ }
68500
+ confineDestination(destinationPath) {
68501
+ const root = this.downloadRoot();
68502
+ const candidate = destinationPath.startsWith("~") || isAbsolute5(destinationPath) ? expandPath(destinationPath) : resolve4(root, destinationPath);
68503
+ try {
68504
+ assertPathWithinRoots(candidate, [root]);
68505
+ } catch {
68506
+ throw new DownloadDestinationRejectedError(destinationPath, root);
68507
+ }
68508
+ return candidate;
68509
+ }
68510
+ pinDownloadUrl(path) {
68511
+ const raw = /^[a-z][a-z0-9+.-]*:/i.test(path) ? path : `${this.account.baseUrl}${path}`;
68512
+ let parsed;
68513
+ try {
68514
+ parsed = new URL(raw);
68515
+ } catch {
68516
+ throw new DownloadUrlRejectedError(path, "not a valid URL");
68517
+ }
68518
+ const origin = new URL(this.account.baseUrl).origin;
68519
+ if (parsed.protocol !== "https:" || parsed.origin !== origin || parsed.username || parsed.password) {
68520
+ throw new DownloadUrlRejectedError(path, `must be an https URL on ${origin}`);
68521
+ }
68522
+ if (!/\/files\/[^/]+/.test(parsed.pathname)) {
68523
+ throw new DownloadUrlRejectedError(path, "must name a Canvas file (a /files/<id> path)");
68524
+ }
68525
+ return parsed.href;
68526
+ }
68426
68527
  async doRawRequest(path, opts) {
68427
68528
  const url4 = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
68428
68529
  const accept = opts.responseType === "text" ? "text/html, text/plain, */*" : "application/json+canvas-string-ids, application/json";
@@ -68535,8 +68636,8 @@ function parseJsonBody2(text) {
68535
68636
  }
68536
68637
  function injectPerPage(pathOrUrl, perPage) {
68537
68638
  if (/[?&]per_page=/.test(pathOrUrl)) return pathOrUrl;
68538
- const sep = pathOrUrl.includes("?") ? "&" : "?";
68539
- return `${pathOrUrl}${sep}per_page=${perPage}`;
68639
+ const sep2 = pathOrUrl.includes("?") ? "&" : "?";
68640
+ return `${pathOrUrl}${sep2}per_page=${perPage}`;
68540
68641
  }
68541
68642
  var TokenExpiredError = class extends Error {
68542
68643
  constructor(mode, detail) {
@@ -68557,6 +68658,26 @@ var CanvasUnreachableError = class extends Error {
68557
68658
  }
68558
68659
  status;
68559
68660
  };
68661
+ var DownloadUrlRejectedError = class extends Error {
68662
+ constructor(url4, reason) {
68663
+ super(`DownloadUrlRejected: ${reason}; use the \`url\` from canvas_list_course_files. Got: ${url4}`);
68664
+ this.url = url4;
68665
+ this.name = "DownloadUrlRejectedError";
68666
+ }
68667
+ url;
68668
+ };
68669
+ var DownloadDestinationRejectedError = class extends Error {
68670
+ constructor(path, root) {
68671
+ super(
68672
+ `DownloadDestinationRejected: destinationPath must be inside ${root} (set CANVAS_OUTPUT_DIR to change it). Got: ${path}`
68673
+ );
68674
+ this.path = path;
68675
+ this.root = root;
68676
+ this.name = "DownloadDestinationRejectedError";
68677
+ }
68678
+ path;
68679
+ root;
68680
+ };
68560
68681
  var InvalidPathError = class extends Error {
68561
68682
  constructor(path) {
68562
68683
  super(`InvalidPath: destinationPath must be a filename, not a directory: ${path}`);
@@ -68963,8 +69084,8 @@ var listArgs4 = external_exports.object({
68963
69084
  contentTypes: external_exports.array(external_exports.string()).optional()
68964
69085
  });
68965
69086
  var downloadArgs = external_exports.object({
68966
- url: external_exports.string().describe("The url field returned by canvas_list_course_files (absolute https URL)."),
68967
- destinationPath: external_exports.string().describe("Absolute path where the file should be written."),
69087
+ url: external_exports.string().describe("The url field returned by canvas_list_course_files (an https /files/ URL on the configured Canvas host; anything else is refused)."),
69088
+ destinationPath: external_exports.string().describe("Where to write the file. Must be inside the download directory (CANVAS_OUTPUT_DIR, default ~/Downloads); a relative path is resolved against it."),
68968
69089
  overwrite: external_exports.boolean().optional()
68969
69090
  });
68970
69091
  function registerFileTools(server, client) {
@@ -68982,8 +69103,8 @@ function registerFileTools(server, client) {
68982
69103
  return textContent(data);
68983
69104
  });
68984
69105
  server.registerTool("canvas_download_file", {
68985
- description: "Download a Canvas file to disk. `url` is the absolute URL from canvas_list_course_files; `destinationPath` is required.",
68986
- annotations: { destructiveHint: true },
69106
+ description: "Download a Canvas file to disk. `url` is the absolute URL from canvas_list_course_files (only file URLs on the configured Canvas host are accepted); `destinationPath` is required and must be inside the download directory (CANVAS_OUTPUT_DIR, default ~/Downloads).",
69107
+ annotations: { destructiveHint: true, openWorldHint: true },
68987
69108
  inputSchema: downloadArgs
68988
69109
  }, async (rawArgs) => {
68989
69110
  const args = downloadArgs.parse(rawArgs);
@@ -68995,8 +69116,8 @@ function registerFileTools(server, client) {
68995
69116
  }
68996
69117
 
68997
69118
  // src/index.ts
68998
- var __dirname = dirname3(fileURLToPath(import.meta.url));
68999
- await loadDotenvSafely({ path: join4(__dirname, "..", ".env"), override: false });
69119
+ var __dirname = dirname4(fileURLToPath(import.meta.url));
69120
+ await loadDotenvSafely({ path: join6(__dirname, "..", ".env"), override: false });
69000
69121
  var resolved = null;
69001
69122
  var configError = null;
69002
69123
  try {
@@ -69006,7 +69127,7 @@ try {
69006
69127
  }
69007
69128
  await runMcp({
69008
69129
  name: "canvas",
69009
- version: "2.1.2",
69130
+ version: "2.1.3",
69010
69131
  // x-release-please-version
69011
69132
  tools: [
69012
69133
  // Outside the `if (resolved)` on purpose: an unconfigured server must
package/dist/client.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { writeFile, stat } from 'fs/promises';
2
2
  import { createSessionCache, reportCacheWriteFailure } from './session-cache.js';
3
- import { dirname } from 'path';
4
- import { createOAuth2Refresher, parseLinkHeader } from '@chrischall/mcp-utils';
3
+ import { dirname, isAbsolute, join, resolve } from 'path';
4
+ import { homedir } from 'os';
5
+ import { assertPathWithinRoots, createOAuth2Refresher, expandPath, parseLinkHeader, readEnvVar, } from '@chrischall/mcp-utils';
5
6
  import { CookieSessionManager } from '@chrischall/mcp-utils/session';
6
7
  import { sessionLogin as defaultSessionLogin } from './session-login.js';
7
8
  // Re-export the fleet-shared RFC 5988 Link parser so existing importers
@@ -19,6 +20,8 @@ export class CanvasClient {
19
20
  * what keeps the path alive once the browser cookie lapses.
20
21
  */
21
22
  refreshSession;
23
+ /** Explicit download root; when unset, resolved per call (see {@link downloadRoot}). */
24
+ outputDir;
22
25
  auth;
23
26
  /** Lazily-built shared refresh_token exchanger (oauth mode only). */
24
27
  oauthRefresh = null;
@@ -34,6 +37,7 @@ export class CanvasClient {
34
37
  */
35
38
  constructor(account, opts = {}) {
36
39
  this.account = account;
40
+ this.outputDir = opts.outputDir;
37
41
  this.sessionLoginFn = opts.sessionLogin ?? defaultSessionLogin;
38
42
  this.refreshSession = opts.refreshSession ?? null;
39
43
  this.auth = new CookieSessionManager({
@@ -78,24 +82,40 @@ export class CanvasClient {
78
82
  }
79
83
  return out;
80
84
  }
85
+ /**
86
+ * Download a Canvas file to disk.
87
+ *
88
+ * Both inputs arrive from tool arguments, which a prompt injection in
89
+ * classmate/teacher-authored Canvas content can steer, so both are pinned
90
+ * BEFORE any credential is attached:
91
+ * - `path` must resolve to the configured Canvas origin (https, same host —
92
+ * the `https://canvas@evil/` userinfo trick parses to a foreign host and is
93
+ * refused) and name a `/files/<id>` resource. Only the initial request is
94
+ * pinned: Canvas legitimately redirects to its file store, and fetch
95
+ * strips Authorization/Cookie on a cross-origin redirect.
96
+ * - `destinationPath` must lie inside the download root (see
97
+ * {@link downloadRoot}), checked through symlinks; a relative path is
98
+ * resolved against that root.
99
+ */
81
100
  async download(path, destinationPath, opts = {}) {
101
+ const url = this.pinDownloadUrl(path);
102
+ const dest = this.confineDestination(destinationPath);
82
103
  let destStat = null;
83
104
  try {
84
- destStat = await stat(destinationPath);
105
+ destStat = await stat(dest);
85
106
  }
86
107
  catch { /* not present, ok */ }
87
108
  if (destStat?.isDirectory())
88
109
  throw new InvalidPathError(destinationPath);
89
110
  if (destStat && !opts.overwrite)
90
111
  throw new FileExistsError(destinationPath);
91
- const parent = dirname(destinationPath);
112
+ const parent = dirname(dest);
92
113
  try {
93
114
  await stat(parent);
94
115
  }
95
116
  catch {
96
117
  throw new ParentDirectoryMissingError(parent);
97
118
  }
98
- const url = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
99
119
  const res = await this.authedFetch(url, {});
100
120
  if (res.status === 401)
101
121
  throw new TokenExpiredError(this.account.mode);
@@ -104,13 +124,54 @@ export class CanvasClient {
104
124
  if (!res.ok)
105
125
  throw new Error(`Canvas download ${res.status} for ${path}`);
106
126
  const buf = new Uint8Array(await res.arrayBuffer());
107
- await writeFile(destinationPath, buf);
127
+ await writeFile(dest, buf);
108
128
  return {
109
- path: destinationPath,
129
+ path: dest,
110
130
  bytes: buf.byteLength,
111
131
  contentType: res.headers.get('content-type') ?? 'application/octet-stream',
112
132
  };
113
133
  }
134
+ /**
135
+ * The directory downloads are confined to: the constructor's `outputDir`,
136
+ * else `CANVAS_OUTPUT_DIR`, else `~/Downloads`. Read per call so an env
137
+ * change needs no client rebuild.
138
+ */
139
+ downloadRoot() {
140
+ return expandPath(this.outputDir ?? readEnvVar('CANVAS_OUTPUT_DIR') ?? join(homedir(), 'Downloads'));
141
+ }
142
+ confineDestination(destinationPath) {
143
+ const root = this.downloadRoot();
144
+ // `~` expands to home; any other relative path is anchored at the root
145
+ // (not the process cwd, which for a desktop-launched server is often `/`).
146
+ const candidate = destinationPath.startsWith('~') || isAbsolute(destinationPath)
147
+ ? expandPath(destinationPath)
148
+ : resolve(root, destinationPath);
149
+ try {
150
+ assertPathWithinRoots(candidate, [root]);
151
+ }
152
+ catch {
153
+ throw new DownloadDestinationRejectedError(destinationPath, root);
154
+ }
155
+ return candidate;
156
+ }
157
+ pinDownloadUrl(path) {
158
+ const raw = /^[a-z][a-z0-9+.-]*:/i.test(path) ? path : `${this.account.baseUrl}${path}`;
159
+ let parsed;
160
+ try {
161
+ parsed = new URL(raw);
162
+ }
163
+ catch {
164
+ throw new DownloadUrlRejectedError(path, 'not a valid URL');
165
+ }
166
+ const origin = new URL(this.account.baseUrl).origin;
167
+ if (parsed.protocol !== 'https:' || parsed.origin !== origin || parsed.username || parsed.password) {
168
+ throw new DownloadUrlRejectedError(path, `must be an https URL on ${origin}`);
169
+ }
170
+ if (!/\/files\/[^/]+/.test(parsed.pathname)) {
171
+ throw new DownloadUrlRejectedError(path, 'must name a Canvas file (a /files/<id> path)');
172
+ }
173
+ return parsed.href;
174
+ }
114
175
  async doRawRequest(path, opts) {
115
176
  const url = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
116
177
  const accept = opts.responseType === 'text'
@@ -269,6 +330,25 @@ export class CanvasUnreachableError extends Error {
269
330
  this.name = 'CanvasUnreachableError';
270
331
  }
271
332
  }
333
+ export class DownloadUrlRejectedError extends Error {
334
+ url;
335
+ constructor(url, reason) {
336
+ super(`DownloadUrlRejected: ${reason}; use the \`url\` from canvas_list_course_files. Got: ${url}`);
337
+ this.url = url;
338
+ this.name = 'DownloadUrlRejectedError';
339
+ }
340
+ }
341
+ export class DownloadDestinationRejectedError extends Error {
342
+ path;
343
+ root;
344
+ constructor(path, root) {
345
+ super(`DownloadDestinationRejected: destinationPath must be inside ${root} ` +
346
+ `(set CANVAS_OUTPUT_DIR to change it). Got: ${path}`);
347
+ this.path = path;
348
+ this.root = root;
349
+ this.name = 'DownloadDestinationRejectedError';
350
+ }
351
+ }
272
352
  export class InvalidPathError extends Error {
273
353
  path;
274
354
  constructor(path) {
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ catch (e) {
47
47
  // host's install-time `tools/list` succeeds.
48
48
  await runMcp({
49
49
  name: 'canvas',
50
- version: '2.1.2', // x-release-please-version
50
+ version: '2.1.3', // x-release-please-version
51
51
  tools: [
52
52
  // Outside the `if (resolved)` on purpose: an unconfigured server must
53
53
  // still expose something that can say WHY (see the tool's docblock).
@@ -6,8 +6,8 @@ const listArgs = z.object({
6
6
  contentTypes: z.array(z.string()).optional(),
7
7
  });
8
8
  const downloadArgs = z.object({
9
- url: z.string().describe('The url field returned by canvas_list_course_files (absolute https URL).'),
10
- destinationPath: z.string().describe('Absolute path where the file should be written.'),
9
+ url: z.string().describe('The url field returned by canvas_list_course_files (an https /files/ URL on the configured Canvas host; anything else is refused).'),
10
+ destinationPath: z.string().describe('Where to write the file. Must be inside the download directory (CANVAS_OUTPUT_DIR, default ~/Downloads); a relative path is resolved against it.'),
11
11
  overwrite: z.boolean().optional(),
12
12
  });
13
13
  export function registerFileTools(server, client) {
@@ -25,8 +25,8 @@ export function registerFileTools(server, client) {
25
25
  return textContent(data);
26
26
  });
27
27
  server.registerTool('canvas_download_file', {
28
- description: "Download a Canvas file to disk. `url` is the absolute URL from canvas_list_course_files; `destinationPath` is required.",
29
- annotations: { destructiveHint: true },
28
+ description: "Download a Canvas file to disk. `url` is the absolute URL from canvas_list_course_files (only file URLs on the configured Canvas host are accepted); `destinationPath` is required and must be inside the download directory (CANVAS_OUTPUT_DIR, default ~/Downloads).",
29
+ annotations: { destructiveHint: true, openWorldHint: true },
30
30
  inputSchema: downloadArgs,
31
31
  }, async (rawArgs) => {
32
32
  const args = downloadArgs.parse(rawArgs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "mcpName": "io.github.chrischall/canvas-parent-mcp",
5
5
  "description": "Canvas LMS (Instructure) MCP server — student/observer read access with optional file download",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/canvas-parent-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.1.2",
9
+ "version": "2.1.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "2.1.2",
14
+ "version": "2.1.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -85,7 +85,7 @@ Precedence when multiple are set: `CANVAS_TOKEN` > username/password > OAuth > f
85
85
 
86
86
  ### Files
87
87
  - `canvas_list_course_files(courseId)` — file metadata
88
- - `canvas_download_file(url, destinationPath)` — download a file to disk
88
+ - `canvas_download_file(url, destinationPath)` — download a file to disk. `url` must be a `/files/` URL on the configured Canvas host; `destinationPath` must be inside `CANVAS_OUTPUT_DIR` (default `~/Downloads`), and a relative path is resolved against it
89
89
 
90
90
  ## Response shape (`view`)
91
91
 
@@ -217,7 +217,9 @@ curl -sL -H "Authorization: Bearer $CANVAS_TOKEN" "$FILE_URL" -o /path/to/destin
217
217
  ```
218
218
 
219
219
  `-L` follows Canvas's redirect to the actual file storage backend. The MCP's
220
- `canvas_download_file` tool additionally refuses to overwrite an existing
220
+ `canvas_download_file` tool additionally only fetches `/files/` URLs on the
221
+ `CANVAS_BASE_URL` host, only writes inside `CANVAS_OUTPUT_DIR` (default
222
+ `~/Downloads`), refuses to overwrite an existing
221
223
  destination unless told to and validates the parent directory exists —
222
224
  replicate that yourself in a script if it matters (`[ -f dest ] && exit 1`,
223
225
  `[ -d "$(dirname dest)" ] || exit 1`).