canvas-parent-mcp 1.2.5 → 1.3.0

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": "1.2.5"
10
+ "version": "1.3.0"
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": "1.2.5",
18
+ "version": "1.3.0",
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": "1.2.5",
4
+ "version": "1.3.0",
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/dist/bundle.js CHANGED
@@ -2991,7 +2991,7 @@ var require_compile = __commonJS({
2991
2991
  const schOrFunc = root.refs[ref];
2992
2992
  if (schOrFunc)
2993
2993
  return schOrFunc;
2994
- let _sch = resolve.call(this, root, ref);
2994
+ let _sch = resolve2.call(this, root, ref);
2995
2995
  if (_sch === void 0) {
2996
2996
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
2997
2997
  const { schemaId } = this.opts;
@@ -3018,7 +3018,7 @@ var require_compile = __commonJS({
3018
3018
  function sameSchemaEnv(s1, s2) {
3019
3019
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3020
3020
  }
3021
- function resolve(root, ref) {
3021
+ function resolve2(root, ref) {
3022
3022
  let sch;
3023
3023
  while (typeof (sch = this.refs[ref]) == "string")
3024
3024
  ref = sch;
@@ -3649,7 +3649,7 @@ var require_fast_uri = __commonJS({
3649
3649
  }
3650
3650
  return uri;
3651
3651
  }
3652
- function resolve(baseURI, relativeURI, options) {
3652
+ function resolve2(baseURI, relativeURI, options) {
3653
3653
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3654
3654
  const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
3655
3655
  const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
@@ -3933,7 +3933,7 @@ var require_fast_uri = __commonJS({
3933
3933
  var fastUri = {
3934
3934
  SCHEMES,
3935
3935
  normalize,
3936
- resolve,
3936
+ resolve: resolve2,
3937
3937
  resolveComponent,
3938
3938
  equal,
3939
3939
  serialize,
@@ -9161,7 +9161,7 @@ var require_websocket = __commonJS({
9161
9161
  var http = __require("http");
9162
9162
  var net = __require("net");
9163
9163
  var tls = __require("tls");
9164
- var { randomBytes, createHash } = __require("crypto");
9164
+ var { randomBytes: randomBytes2, createHash } = __require("crypto");
9165
9165
  var { Duplex, Readable } = __require("stream");
9166
9166
  var { URL: URL2 } = __require("url");
9167
9167
  var PerMessageDeflate2 = require_permessage_deflate();
@@ -9699,7 +9699,7 @@ var require_websocket = __commonJS({
9699
9699
  }
9700
9700
  }
9701
9701
  const defaultPort = isSecure ? 443 : 80;
9702
- const key = randomBytes(16).toString("base64");
9702
+ const key = randomBytes2(16).toString("base64");
9703
9703
  const request = isSecure ? https.request : http.request;
9704
9704
  const protocolSet = /* @__PURE__ */ new Set();
9705
9705
  let perMessageDeflate;
@@ -10593,7 +10593,7 @@ var require_websocket_server = __commonJS({
10593
10593
  });
10594
10594
 
10595
10595
  // src/index.ts
10596
- import { dirname as dirname2, join as join3 } from "path";
10596
+ import { dirname as dirname3, join as join4 } from "path";
10597
10597
  import { fileURLToPath } from "url";
10598
10598
 
10599
10599
  // node_modules/zod/v3/helpers/util.js
@@ -32554,7 +32554,7 @@ var Protocol = class {
32554
32554
  return;
32555
32555
  }
32556
32556
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
32557
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
32557
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
32558
32558
  options?.signal?.throwIfAborted();
32559
32559
  }
32560
32560
  } catch (error51) {
@@ -32571,7 +32571,7 @@ var Protocol = class {
32571
32571
  */
32572
32572
  request(request, resultSchema, options) {
32573
32573
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
32574
- return new Promise((resolve, reject) => {
32574
+ return new Promise((resolve2, reject) => {
32575
32575
  const earlyReject = (error51) => {
32576
32576
  reject(error51);
32577
32577
  };
@@ -32649,7 +32649,7 @@ var Protocol = class {
32649
32649
  if (!parseResult.success) {
32650
32650
  reject(parseResult.error);
32651
32651
  } else {
32652
- resolve(parseResult.data);
32652
+ resolve2(parseResult.data);
32653
32653
  }
32654
32654
  } catch (error51) {
32655
32655
  reject(error51);
@@ -32910,12 +32910,12 @@ var Protocol = class {
32910
32910
  }
32911
32911
  } catch {
32912
32912
  }
32913
- return new Promise((resolve, reject) => {
32913
+ return new Promise((resolve2, reject) => {
32914
32914
  if (signal.aborted) {
32915
32915
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
32916
32916
  return;
32917
32917
  }
32918
- const timeoutId = setTimeout(resolve, interval);
32918
+ const timeoutId = setTimeout(resolve2, interval);
32919
32919
  signal.addEventListener("abort", () => {
32920
32920
  clearTimeout(timeoutId);
32921
32921
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -34006,7 +34006,7 @@ var McpServer = class {
34006
34006
  let task = createTaskResult.task;
34007
34007
  const pollInterval = task.pollInterval ?? 5e3;
34008
34008
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
34009
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
34009
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
34010
34010
  const updatedTask = await extra.taskStore.getTask(taskId);
34011
34011
  if (!updatedTask) {
34012
34012
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -34670,64 +34670,17 @@ var StdioServerTransport = class {
34670
34670
  this.onclose?.();
34671
34671
  }
34672
34672
  send(message) {
34673
- return new Promise((resolve) => {
34673
+ return new Promise((resolve2) => {
34674
34674
  const json2 = serializeMessage(message);
34675
34675
  if (this._stdout.write(json2)) {
34676
- resolve();
34676
+ resolve2();
34677
34677
  } else {
34678
- this._stdout.once("drain", resolve);
34678
+ this._stdout.once("drain", resolve2);
34679
34679
  }
34680
34680
  });
34681
34681
  }
34682
34682
  };
34683
34683
 
34684
- // node_modules/@chrischall/mcp-utils/dist/server/index.js
34685
- async function createMcpServer(opts) {
34686
- const server = new McpServer({ name: opts.name, version: opts.version });
34687
- if (opts.banner !== void 0) {
34688
- console.error(opts.banner);
34689
- }
34690
- const deps = opts.deps;
34691
- for (const register of opts.tools) {
34692
- await register(server, deps);
34693
- }
34694
- return server;
34695
- }
34696
- function withGracefulShutdown(server, opts = {}) {
34697
- const shouldExit = opts.exit ?? true;
34698
- let shuttingDown = false;
34699
- const handler = (signal) => {
34700
- if (shuttingDown)
34701
- return;
34702
- shuttingDown = true;
34703
- void (async () => {
34704
- try {
34705
- if (opts.onSignal)
34706
- await opts.onSignal(signal);
34707
- await server.close();
34708
- } catch (err) {
34709
- console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
34710
- } finally {
34711
- if (shouldExit)
34712
- process.exit(0);
34713
- }
34714
- })();
34715
- };
34716
- process.on("SIGINT", () => handler("SIGINT"));
34717
- process.on("SIGTERM", () => handler("SIGTERM"));
34718
- }
34719
- async function runMcp(opts) {
34720
- const server = await createMcpServer(opts);
34721
- const shutdown = opts.shutdown ?? true;
34722
- if (shutdown !== false) {
34723
- withGracefulShutdown(server, shutdown === true ? {} : shutdown);
34724
- }
34725
- const spec = opts.transport ?? "stdio";
34726
- const transport = spec === "stdio" ? new StdioServerTransport() : spec;
34727
- await server.connect(transport);
34728
- return server;
34729
- }
34730
-
34731
34684
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
34732
34685
  var DEFAULT_ERROR_MESSAGE_MAX = 500;
34733
34686
  var McpToolError = class extends Error {
@@ -34785,6 +34738,81 @@ function textResult(data) {
34785
34738
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
34786
34739
  };
34787
34740
  }
34741
+ function errorResult(message) {
34742
+ return {
34743
+ content: [{ type: "text", text: redactSecrets(message) }],
34744
+ isError: true
34745
+ };
34746
+ }
34747
+
34748
+ // node_modules/@chrischall/mcp-utils/dist/server/index.js
34749
+ function hintResultOrRethrow(err) {
34750
+ if (err instanceof McpToolError && err.hint) {
34751
+ return errorResult(`${err.message}
34752
+
34753
+ Hint: ${err.hint}`);
34754
+ }
34755
+ throw err;
34756
+ }
34757
+ function surfaceToolHints(server) {
34758
+ const register = server.registerTool.bind(server);
34759
+ server.registerTool = (name, config2, cb) => register(name, config2, (...args) => {
34760
+ let result;
34761
+ try {
34762
+ result = cb(...args);
34763
+ } catch (err) {
34764
+ return hintResultOrRethrow(err);
34765
+ }
34766
+ return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
34767
+ });
34768
+ }
34769
+ async function createMcpServer(opts) {
34770
+ const server = new McpServer({ name: opts.name, version: opts.version });
34771
+ if (opts.surfaceHints !== false)
34772
+ surfaceToolHints(server);
34773
+ if (opts.banner !== void 0) {
34774
+ console.error(opts.banner);
34775
+ }
34776
+ const deps = opts.deps;
34777
+ for (const register of opts.tools) {
34778
+ await register(server, deps);
34779
+ }
34780
+ return server;
34781
+ }
34782
+ function withGracefulShutdown(server, opts = {}) {
34783
+ const shouldExit = opts.exit ?? true;
34784
+ let shuttingDown = false;
34785
+ const handler = (signal) => {
34786
+ if (shuttingDown)
34787
+ return;
34788
+ shuttingDown = true;
34789
+ void (async () => {
34790
+ try {
34791
+ if (opts.onSignal)
34792
+ await opts.onSignal(signal);
34793
+ await server.close();
34794
+ } catch (err) {
34795
+ console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
34796
+ } finally {
34797
+ if (shouldExit)
34798
+ process.exit(0);
34799
+ }
34800
+ })();
34801
+ };
34802
+ process.on("SIGINT", () => handler("SIGINT"));
34803
+ process.on("SIGTERM", () => handler("SIGTERM"));
34804
+ }
34805
+ async function runMcp(opts) {
34806
+ const server = await createMcpServer(opts);
34807
+ const shutdown = opts.shutdown ?? true;
34808
+ if (shutdown !== false) {
34809
+ withGracefulShutdown(server, shutdown === true ? {} : shutdown);
34810
+ }
34811
+ const spec = opts.transport ?? "stdio";
34812
+ const transport = spec === "stdio" ? new StdioServerTransport() : spec;
34813
+ await server.connect(transport);
34814
+ return server;
34815
+ }
34788
34816
 
34789
34817
  // node_modules/@chrischall/mcp-utils/dist/config/index.js
34790
34818
  var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
@@ -34799,6 +34827,20 @@ function readEnvVar(key, opts = {}) {
34799
34827
  }
34800
34828
  return opts.default;
34801
34829
  }
34830
+ var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
34831
+ var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
34832
+ function parseBoolEnv(key, opts = {}) {
34833
+ const fallback = opts.default ?? false;
34834
+ const raw = readEnvVar(key, { env: opts.env });
34835
+ if (raw === void 0)
34836
+ return fallback;
34837
+ const token = raw.toLowerCase();
34838
+ if (TRUE_TOKENS.has(token))
34839
+ return true;
34840
+ if (FALSE_TOKENS.has(token))
34841
+ return false;
34842
+ return fallback;
34843
+ }
34802
34844
  async function loadDotenvSafely(opts = {}) {
34803
34845
  try {
34804
34846
  const mod = await import(
@@ -36191,7 +36233,7 @@ var DEFAULT_BIND_TIMEOUT_MS = 5e3;
36191
36233
  async function electRole(opts) {
36192
36234
  const server = createServer();
36193
36235
  const bindTimeoutMs = opts.bindTimeoutMs ?? DEFAULT_BIND_TIMEOUT_MS;
36194
- return new Promise((resolve, reject) => {
36236
+ return new Promise((resolve2, reject) => {
36195
36237
  let timer;
36196
36238
  const clearBindTimer = () => {
36197
36239
  if (timer) {
@@ -36207,7 +36249,7 @@ async function electRole(opts) {
36207
36249
  server.close();
36208
36250
  } catch {
36209
36251
  }
36210
- resolve({ role: "peer" });
36252
+ resolve2({ role: "peer" });
36211
36253
  } else {
36212
36254
  reject(e);
36213
36255
  }
@@ -36215,7 +36257,7 @@ async function electRole(opts) {
36215
36257
  const onListening = () => {
36216
36258
  clearBindTimer();
36217
36259
  server.removeListener("error", onError);
36218
- resolve({ role: "host", server });
36260
+ resolve2({ role: "host", server });
36219
36261
  };
36220
36262
  server.once("error", onError);
36221
36263
  server.once("listening", onListening);
@@ -36561,8 +36603,8 @@ async function startHost(opts) {
36561
36603
  let rejectOwnSession;
36562
36604
  let ownSessionReady;
36563
36605
  function resetSessionPromise() {
36564
- ownSessionReady = new Promise((resolve, reject) => {
36565
- resolveOwnSession = resolve;
36606
+ ownSessionReady = new Promise((resolve2, reject) => {
36607
+ resolveOwnSession = resolve2;
36566
36608
  rejectOwnSession = reject;
36567
36609
  });
36568
36610
  ownSessionReady.catch(() => {
@@ -36783,7 +36825,7 @@ async function startHost(opts) {
36783
36825
  });
36784
36826
  });
36785
36827
  return {
36786
- close: () => new Promise((resolve) => {
36828
+ close: () => new Promise((resolve2) => {
36787
36829
  for (const client of wss.clients) {
36788
36830
  try {
36789
36831
  client.terminate();
@@ -36791,7 +36833,7 @@ async function startHost(opts) {
36791
36833
  }
36792
36834
  }
36793
36835
  wss.close(() => {
36794
- opts.httpServer.close(() => resolve());
36836
+ opts.httpServer.close(() => resolve2());
36795
36837
  });
36796
36838
  }),
36797
36839
  sendOwnInner: async (inner) => {
@@ -36821,8 +36863,8 @@ async function startHost(opts) {
36821
36863
  var enc3 = new TextEncoder();
36822
36864
  async function startPeer(opts) {
36823
36865
  const ws = new import_websocket.default(`ws://${opts.host}:${opts.port}`);
36824
- await new Promise((resolve, reject) => {
36825
- ws.once("open", () => resolve());
36866
+ await new Promise((resolve2, reject) => {
36867
+ ws.once("open", () => resolve2());
36826
36868
  ws.once("error", reject);
36827
36869
  });
36828
36870
  const hello = await buildServerHello({
@@ -36852,8 +36894,8 @@ async function startPeer(opts) {
36852
36894
  let pendingPairCode = null;
36853
36895
  let resolveFirstReady;
36854
36896
  let rejectFirstReady;
36855
- const sessionPromise = new Promise((resolve, reject) => {
36856
- resolveFirstReady = resolve;
36897
+ const sessionPromise = new Promise((resolve2, reject) => {
36898
+ resolveFirstReady = resolve2;
36857
36899
  rejectFirstReady = reject;
36858
36900
  });
36859
36901
  let extensionHello = null;
@@ -37054,6 +37096,17 @@ function classifyBridgeError(err) {
37054
37096
  }
37055
37097
 
37056
37098
  // node_modules/@fetchproxy/server/dist/ws-server.js
37099
+ function envWsPort() {
37100
+ const raw = process.env.FETCHPROXY_WS_PORT;
37101
+ if (raw === void 0 || raw.trim() === "")
37102
+ return void 0;
37103
+ if (!/^\d+$/.test(raw.trim()))
37104
+ return void 0;
37105
+ const port = Number(raw.trim());
37106
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
37107
+ return void 0;
37108
+ return port;
37109
+ }
37057
37110
  var FetchproxyProtocolError = class extends Error {
37058
37111
  constructor(message) {
37059
37112
  super(message);
@@ -37301,7 +37354,7 @@ var FetchproxyServer = class {
37301
37354
  }
37302
37355
  }
37303
37356
  this.opts = {
37304
- port: opts.port ?? 37149,
37357
+ port: opts.port ?? envWsPort() ?? 37149,
37305
37358
  host: opts.host ?? "127.0.0.1",
37306
37359
  serverName: opts.serverName,
37307
37360
  version: opts.version,
@@ -37731,8 +37784,8 @@ var FetchproxyServer = class {
37731
37784
  async _fetchOnceWithTimeout(init) {
37732
37785
  const id = this.nextRequestId++;
37733
37786
  const inner = { type: "request", id, op: "fetch", init };
37734
- const pending = new Promise((resolve) => {
37735
- this.pending.set(id, resolve);
37787
+ const pending = new Promise((resolve2) => {
37788
+ this.pending.set(id, resolve2);
37736
37789
  });
37737
37790
  await this.sendInnerFrame(inner);
37738
37791
  const timeoutMs = this.opts.fetchTimeoutMs;
@@ -37743,12 +37796,12 @@ var FetchproxyServer = class {
37743
37796
  try {
37744
37797
  return await Promise.race([
37745
37798
  pending,
37746
- new Promise((resolve) => {
37799
+ new Promise((resolve2) => {
37747
37800
  timer = setTimeout(() => {
37748
37801
  this.pending.delete(id);
37749
37802
  const elapsedMs = Date.now() - start;
37750
37803
  const error51 = `fetchproxy: ${init.url} did not respond within ${timeoutMs}ms`;
37751
- resolve({
37804
+ resolve2({
37752
37805
  ok: false,
37753
37806
  error: error51,
37754
37807
  kind: "timeout",
@@ -38110,8 +38163,8 @@ var FetchproxyServer = class {
38110
38163
  const tabUrl = `https://${host}/`;
38111
38164
  inner = { type: "request", id, op: "read_cookies", init: { tabUrl } };
38112
38165
  }
38113
- const pending = new Promise((resolve) => {
38114
- this.pendingReadCookies.set(id, resolve);
38166
+ const pending = new Promise((resolve2) => {
38167
+ this.pendingReadCookies.set(id, resolve2);
38115
38168
  });
38116
38169
  await this.sendInnerFrame(inner);
38117
38170
  const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
@@ -38169,8 +38222,8 @@ var FetchproxyServer = class {
38169
38222
  ...cookiePath !== void 0 ? { path: cookiePath } : {}
38170
38223
  }
38171
38224
  };
38172
- const pending = new Promise((resolve, reject) => {
38173
- this.pendingWriteCookies.set(id, { resolve, reject });
38225
+ const pending = new Promise((resolve2, reject) => {
38226
+ this.pendingWriteCookies.set(id, { resolve: resolve2, reject });
38174
38227
  });
38175
38228
  await this.sendInnerFrame(inner);
38176
38229
  return this._withVerbTimeout(pending, this.pendingWriteCookies, id, `https://${host}`);
@@ -38232,8 +38285,8 @@ var FetchproxyServer = class {
38232
38285
  ...opts.pointers ? { pointers: { ...opts.pointers } } : {}
38233
38286
  }
38234
38287
  };
38235
- const pending = new Promise((resolve, reject) => {
38236
- this.pendingStorage.set(id, { resolve, reject });
38288
+ const pending = new Promise((resolve2, reject) => {
38289
+ this.pendingStorage.set(id, { resolve: resolve2, reject });
38237
38290
  });
38238
38291
  await this.sendInnerFrame(inner);
38239
38292
  return this._withVerbTimeout(pending, this.pendingStorage, id, `https://${host}`);
@@ -38337,8 +38390,8 @@ var FetchproxyServer = class {
38337
38390
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
38338
38391
  }
38339
38392
  };
38340
- const pending = new Promise((resolve, reject) => {
38341
- this.pendingCapture.set(id, { resolve, reject });
38393
+ const pending = new Promise((resolve2, reject) => {
38394
+ this.pendingCapture.set(id, { resolve: resolve2, reject });
38342
38395
  });
38343
38396
  await this.sendInnerFrame(inner);
38344
38397
  return this._withVerbTimeout(pending, this.pendingCapture, id, `https://${opts.host}${opts.path ?? "/*"}`);
@@ -38422,8 +38475,8 @@ var FetchproxyServer = class {
38422
38475
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
38423
38476
  }
38424
38477
  };
38425
- const pending = new Promise((resolve, reject) => {
38426
- this.pendingRedirect.set(id, { resolve, reject });
38478
+ const pending = new Promise((resolve2, reject) => {
38479
+ this.pendingRedirect.set(id, { resolve: resolve2, reject });
38427
38480
  });
38428
38481
  await this.sendInnerFrame(inner);
38429
38482
  return this._withVerbTimeout(pending, this.pendingRedirect, id, `https://${opts.host}${opts.path ?? "/*"}`);
@@ -38504,8 +38557,8 @@ var FetchproxyServer = class {
38504
38557
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
38505
38558
  }
38506
38559
  };
38507
- const pending = new Promise((resolve, reject) => {
38508
- this.pendingDownload.set(id, { resolve, reject });
38560
+ const pending = new Promise((resolve2, reject) => {
38561
+ this.pendingDownload.set(id, { resolve: resolve2, reject });
38509
38562
  });
38510
38563
  await this.sendInnerFrame(inner);
38511
38564
  return this._withVerbTimeout(pending, this.pendingDownload, id, opts.url);
@@ -38552,8 +38605,8 @@ var FetchproxyServer = class {
38552
38605
  keys: [...opts.keys]
38553
38606
  }
38554
38607
  };
38555
- const pending = new Promise((resolve, reject) => {
38556
- this.pendingIdb.set(id, { resolve, reject });
38608
+ const pending = new Promise((resolve2, reject) => {
38609
+ this.pendingIdb.set(id, { resolve: resolve2, reject });
38557
38610
  });
38558
38611
  await this.sendInnerFrame(inner);
38559
38612
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
@@ -38592,8 +38645,8 @@ var FetchproxyServer = class {
38592
38645
  op: "read_dom",
38593
38646
  init: { origin, names: [...opts.names] }
38594
38647
  };
38595
- const pending = new Promise((resolve, reject) => {
38596
- this.pendingStorage.set(id, { resolve, reject });
38648
+ const pending = new Promise((resolve2, reject) => {
38649
+ this.pendingStorage.set(id, { resolve: resolve2, reject });
38597
38650
  });
38598
38651
  await this.sendInnerFrame(inner);
38599
38652
  return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
@@ -38638,8 +38691,8 @@ var FetchproxyServer = class {
38638
38691
  ...opts.tabUrl !== void 0 ? { tabUrl: opts.tabUrl } : {}
38639
38692
  }
38640
38693
  };
38641
- const pending = new Promise((resolve, reject) => {
38642
- this.pendingGraphql.set(id, { resolve, reject });
38694
+ const pending = new Promise((resolve2, reject) => {
38695
+ this.pendingGraphql.set(id, { resolve: resolve2, reject });
38643
38696
  });
38644
38697
  await this.sendInnerFrame(inner);
38645
38698
  return this._withVerbTimeout(pending, this.pendingGraphql, id, opts.name);
@@ -39173,7 +39226,7 @@ function loadAccount(env = process.env) {
39173
39226
  // package.json
39174
39227
  var package_default = {
39175
39228
  name: "canvas-parent-mcp",
39176
- version: "1.2.5",
39229
+ version: "1.3.0",
39177
39230
  mcpName: "io.github.chrischall/canvas-parent-mcp",
39178
39231
  description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
39179
39232
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -39207,17 +39260,20 @@ var package_default = {
39207
39260
  ".claude-plugin",
39208
39261
  "skills",
39209
39262
  ".mcp.json",
39210
- "server.json"
39263
+ "server.json",
39264
+ "mint.yaml"
39211
39265
  ],
39212
39266
  scripts: {
39213
39267
  build: "tsc && npm run bundle",
39214
39268
  bundle: `esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from "module"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js`,
39215
39269
  dev: "node --env-file=.env dist/index.js",
39216
- test: "vitest run",
39217
- "test:watch": "vitest"
39270
+ test: "npm run typecheck && vitest run",
39271
+ "test:coverage": "npm run typecheck && vitest run --coverage",
39272
+ "test:watch": "vitest",
39273
+ typecheck: "tsc -p tsconfig.json --noEmit"
39218
39274
  },
39219
39275
  dependencies: {
39220
- "@chrischall/mcp-utils": "^0.14.0",
39276
+ "@chrischall/mcp-utils": "^0.17.1",
39221
39277
  "@fetchproxy/bootstrap": "^2.0.0",
39222
39278
  "@fetchproxy/server": "^2.0.0",
39223
39279
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -39327,10 +39383,127 @@ async function liftBrowserCookie(declaredDomain, baseHost) {
39327
39383
 
39328
39384
  // src/client.ts
39329
39385
  import { writeFile as writeFile3, stat } from "fs/promises";
39330
- import { dirname } from "path";
39331
39386
 
39332
39387
  // node_modules/@chrischall/mcp-utils/dist/session/index.js
39388
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
39389
+ import { dirname, join as join3, resolve } from "node:path";
39390
+ import { homedir as homedir2 } from "node:os";
39391
+ import { randomBytes, createHmac } from "node:crypto";
39392
+ function bindingDigest(secret, salt) {
39393
+ return createHmac("sha256", salt).update(secret).digest("hex").slice(0, 32);
39394
+ }
39395
+ function isEnvelope(raw) {
39396
+ return raw !== null && typeof raw === "object" && raw.v === 1 && "state" in raw;
39397
+ }
39398
+ var StatePersistenceError = class extends Error {
39399
+ cause;
39400
+ constructor(cause) {
39401
+ super(cause instanceof Error ? cause.message : String(cause));
39402
+ this.name = "StatePersistenceError";
39403
+ this.cause = cause;
39404
+ Object.setPrototypeOf(this, new.target.prototype);
39405
+ }
39406
+ };
39407
+ function createFileStatePersistence(opts) {
39408
+ const { filePath, validate } = opts;
39409
+ const secret = opts.boundTo;
39410
+ return {
39411
+ load() {
39412
+ if (!existsSync(filePath))
39413
+ return null;
39414
+ try {
39415
+ const raw = JSON.parse(readFileSync(filePath, "utf8"));
39416
+ let state;
39417
+ if (isEnvelope(raw)) {
39418
+ const b = raw.boundTo;
39419
+ if (secret === void 0) {
39420
+ if (b !== void 0)
39421
+ return null;
39422
+ } else {
39423
+ if (b === void 0)
39424
+ return null;
39425
+ if (bindingDigest(secret, b.salt) !== b.digest)
39426
+ return null;
39427
+ }
39428
+ state = raw.state;
39429
+ } else {
39430
+ if (secret !== void 0)
39431
+ return null;
39432
+ state = raw;
39433
+ }
39434
+ if (validate !== void 0)
39435
+ return validate(state);
39436
+ return state;
39437
+ } catch {
39438
+ return null;
39439
+ }
39440
+ },
39441
+ save(state) {
39442
+ const dir = dirname(filePath);
39443
+ const tmp = `${filePath}.tmp-${randomBytes(6).toString("hex")}`;
39444
+ const dirExisted = existsSync(dir);
39445
+ try {
39446
+ mkdirSync(dir, { recursive: true, mode: 448 });
39447
+ if (!dirExisted)
39448
+ chmodSync(dir, 448);
39449
+ let boundTo;
39450
+ if (secret !== void 0) {
39451
+ const salt = randomBytes(16).toString("hex");
39452
+ boundTo = { salt, digest: bindingDigest(secret, salt) };
39453
+ }
39454
+ const envelope = { v: 1, ...boundTo !== void 0 ? { boundTo } : {}, state };
39455
+ writeFileSync(tmp, JSON.stringify(envelope, null, 2), { mode: 384 });
39456
+ chmodSync(tmp, 384);
39457
+ renameSync(tmp, filePath);
39458
+ chmodSync(filePath, 384);
39459
+ } catch (err) {
39460
+ try {
39461
+ if (existsSync(tmp))
39462
+ unlinkSync(tmp);
39463
+ } catch {
39464
+ }
39465
+ throw err;
39466
+ }
39467
+ },
39468
+ clear() {
39469
+ try {
39470
+ if (existsSync(filePath))
39471
+ unlinkSync(filePath);
39472
+ } catch {
39473
+ }
39474
+ }
39475
+ };
39476
+ }
39477
+ function homeOf(env) {
39478
+ return readEnvVar("HOME", { env }) ?? homedir2();
39479
+ }
39480
+ function resolveStateDir(opts = {}) {
39481
+ const env = opts.env;
39482
+ return join3(readEnvVar("MCP_DATA_DIR", { env }) ?? homeOf(env), opts.subdir ?? "");
39483
+ }
39484
+ function resolveStateFile(opts) {
39485
+ const override = opts.envVar !== void 0 ? readEnvVar(opts.envVar, { env: opts.env }) : void 0;
39486
+ if (override !== void 0) {
39487
+ if (override === "~")
39488
+ return homeOf(opts.env);
39489
+ if (override.startsWith("~/"))
39490
+ return join3(homeOf(opts.env), override.slice(2));
39491
+ return resolve(override);
39492
+ }
39493
+ const dirOpts = { env: opts.env };
39494
+ if (opts.subdir !== void 0)
39495
+ dirOpts.subdir = opts.subdir;
39496
+ return join3(resolveStateDir(dirOpts), opts.fileName);
39497
+ }
39333
39498
  var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
39499
+ function isPersistedCookieSession(raw) {
39500
+ if (raw === null || typeof raw !== "object")
39501
+ return false;
39502
+ const r = raw;
39503
+ if (typeof r.sessionAt !== "number" || !Number.isFinite(r.sessionAt))
39504
+ return false;
39505
+ return typeof r.session === "object" && r.session !== null;
39506
+ }
39334
39507
  var CookieSessionManager = class {
39335
39508
  session;
39336
39509
  inFlight;
@@ -39344,6 +39517,17 @@ var CookieSessionManager = class {
39344
39517
  maxAgeMs;
39345
39518
  now;
39346
39519
  onReplayLoginErrorFn;
39520
+ persistence;
39521
+ onPersistErrorFn;
39522
+ /** Persistence is consulted once per process; a miss must not be re-read. */
39523
+ persistenceRead = false;
39524
+ /**
39525
+ * Serializes persistence writes. `seed()` and `invalidate()` are synchronous
39526
+ * by contract and so fire-and-forget their save/clear; with an async backend a
39527
+ * slow save could otherwise land AFTER the clear that followed it and leave an
39528
+ * invalidated session on disk.
39529
+ */
39530
+ persistChain = Promise.resolve();
39347
39531
  constructor(opts) {
39348
39532
  this.loginFn = opts.login;
39349
39533
  this.isExpiredFn = opts.isExpired ?? (() => false);
@@ -39351,6 +39535,8 @@ var CookieSessionManager = class {
39351
39535
  this.maxAgeMs = opts.maxAgeMs;
39352
39536
  this.now = opts.now ?? Date.now;
39353
39537
  this.onReplayLoginErrorFn = opts.onReplayLoginError;
39538
+ this.persistence = opts.persistence;
39539
+ this.onPersistErrorFn = opts.onPersistError;
39354
39540
  }
39355
39541
  /** The current session, or `undefined` before the first successful login. */
39356
39542
  get current() {
@@ -39399,6 +39585,8 @@ var CookieSessionManager = class {
39399
39585
  this.session = session;
39400
39586
  this.sessionAt = this.now();
39401
39587
  this.inFlight = void 0;
39588
+ this.persistenceRead = true;
39589
+ void this.persist(session, this.sessionAt);
39402
39590
  }
39403
39591
  /**
39404
39592
  * One login attempt. Self-clears `inFlight` on settle so a rejected login
@@ -39410,13 +39598,25 @@ var CookieSessionManager = class {
39410
39598
  const holder = {};
39411
39599
  holder.p = (async () => {
39412
39600
  try {
39601
+ const restored = this.persistence !== void 0 && !this.persistenceRead ? await this.restoreFromPersistence() : null;
39602
+ if (restored !== null) {
39603
+ if (this.inFlight === holder.p) {
39604
+ this.session = restored.session;
39605
+ this.sessionAt = restored.sessionAt;
39606
+ }
39607
+ return restored.session;
39608
+ }
39413
39609
  const session = await this.loginFn();
39610
+ const at = this.now();
39414
39611
  if (this.inFlight === holder.p) {
39415
39612
  this.session = session;
39416
- this.sessionAt = this.now();
39613
+ this.sessionAt = at;
39417
39614
  }
39615
+ await this.persist(session, at);
39418
39616
  return session;
39419
39617
  } catch (err) {
39618
+ if (err instanceof StatePersistenceError)
39619
+ throw err.cause;
39420
39620
  if (this.isPermanentErrorFn(err))
39421
39621
  this.permanentError = err;
39422
39622
  throw err;
@@ -39436,6 +39636,71 @@ var CookieSessionManager = class {
39436
39636
  invalidate() {
39437
39637
  this.session = void 0;
39438
39638
  this.inFlight = void 0;
39639
+ void this.clearPersisted();
39640
+ }
39641
+ /**
39642
+ * The persisted session, if there is one worth using. Read at most once per
39643
+ * process — after that the in-memory session (or its absence) is the truth,
39644
+ * so an invalidate() cannot be undone by a stale file.
39645
+ */
39646
+ async restoreFromPersistence() {
39647
+ if (this.persistence === void 0 || this.persistenceRead)
39648
+ return null;
39649
+ this.persistenceRead = true;
39650
+ try {
39651
+ let raw = null;
39652
+ await this.enqueuePersist(async () => {
39653
+ raw = await this.persistence?.load();
39654
+ });
39655
+ if (!isPersistedCookieSession(raw))
39656
+ return null;
39657
+ if (this.maxAgeMs !== void 0 && this.now() - raw.sessionAt >= this.maxAgeMs)
39658
+ return null;
39659
+ return raw;
39660
+ } catch {
39661
+ return null;
39662
+ }
39663
+ }
39664
+ /**
39665
+ * Append a persistence op to the chain, preserving call order.
39666
+ *
39667
+ * The RETURNED promise can reject — that is the whole `StatePersistenceError`
39668
+ * path, and the awaited login write depends on it. Only the retained chain is
39669
+ * swallowed, so one failed write cannot poison every later one.
39670
+ */
39671
+ enqueuePersist(op) {
39672
+ const next = this.persistChain.then(op);
39673
+ this.persistChain = next.catch(() => void 0);
39674
+ return next;
39675
+ }
39676
+ /** Write the session. Silent unless `onPersistError` throws. */
39677
+ persist(session, sessionAt) {
39678
+ return this.enqueuePersist(async () => {
39679
+ if (this.persistence === void 0)
39680
+ return;
39681
+ try {
39682
+ await this.persistence.save({ session, sessionAt });
39683
+ } catch (err) {
39684
+ if (this.onPersistErrorFn === void 0)
39685
+ return;
39686
+ try {
39687
+ this.onPersistErrorFn(err);
39688
+ } catch (hookErr) {
39689
+ throw new StatePersistenceError(hookErr);
39690
+ }
39691
+ }
39692
+ });
39693
+ }
39694
+ /** Discard the persisted session. Never throws. */
39695
+ clearPersisted() {
39696
+ return this.enqueuePersist(async () => {
39697
+ if (this.persistence?.clear === void 0)
39698
+ return;
39699
+ try {
39700
+ await this.persistence.clear();
39701
+ } catch {
39702
+ }
39703
+ });
39439
39704
  }
39440
39705
  /**
39441
39706
  * Run an authenticated `call` with the current session and reactive
@@ -39469,6 +39734,57 @@ var CookieSessionManager = class {
39469
39734
  }
39470
39735
  };
39471
39736
 
39737
+ // src/session-cache.ts
39738
+ function sessionCachePath(env = process.env) {
39739
+ return resolveStateFile({
39740
+ env,
39741
+ envVar: "CANVAS_SESSION_FILE",
39742
+ subdir: ".canvas-parent-mcp",
39743
+ fileName: "session.json"
39744
+ });
39745
+ }
39746
+ function isRecord(raw) {
39747
+ if (raw === null || typeof raw !== "object") return false;
39748
+ const r = raw;
39749
+ if (typeof r.sessionAt !== "number") return false;
39750
+ const s = r.session;
39751
+ if (s === null || typeof s !== "object") return false;
39752
+ const h = s.headers;
39753
+ if (h === null || typeof h !== "object" || Object.keys(h).length === 0) return false;
39754
+ if (Object.values(h).some((v) => typeof v !== "string")) return false;
39755
+ return s.accessTokenExpiresAt === void 0 || typeof s.accessTokenExpiresAt === "number";
39756
+ }
39757
+ function bindingFor(account, browserBacked) {
39758
+ if (account.mode === "token") return null;
39759
+ if (account.mode === "session") {
39760
+ if (browserBacked) return null;
39761
+ return ["session", account.baseUrl, account.username.trim().toLowerCase(), account.password].join(
39762
+ "\0"
39763
+ );
39764
+ }
39765
+ return ["oauth", account.baseUrl, account.clientId, account.refreshToken].join("\0");
39766
+ }
39767
+ function createSessionCache(account, opts = {}) {
39768
+ const env = opts.env ?? process.env;
39769
+ if (!parseBoolEnv("CANVAS_SESSION_CACHE", { env, default: true })) return null;
39770
+ const boundTo = bindingFor(account, opts.browserBacked === true);
39771
+ if (boundTo === null) return null;
39772
+ return createFileStatePersistence({
39773
+ filePath: sessionCachePath(env),
39774
+ boundTo,
39775
+ validate: (raw) => isRecord(raw) ? raw : null
39776
+ });
39777
+ }
39778
+ function reportCacheWriteFailure(err) {
39779
+ const detail = err instanceof Error ? err.message : String(err);
39780
+ console.error(
39781
+ `[canvas-parent-mcp] could not cache the session (${detail}); continuing without the cache \u2014 every restart will authenticate again until this is fixed.`
39782
+ );
39783
+ }
39784
+
39785
+ // src/client.ts
39786
+ import { dirname as dirname2 } from "path";
39787
+
39472
39788
  // src/session-login.ts
39473
39789
  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";
39474
39790
  var SessionLoginError = class extends Error {
@@ -39589,7 +39905,9 @@ var CanvasClient = class {
39589
39905
  // account with neither a lift nor credentials still can't — those 401s
39590
39906
  // fall through as a Response and `mapStatus` turns them into a
39591
39907
  // TokenExpiredError.
39592
- isExpired: (res) => res.status === 401 && this.canReauth()
39908
+ isExpired: (res) => res.status === 401 && this.canReauth(),
39909
+ persistence: createSessionCache(account, { browserBacked: this.refreshSession !== null }) ?? void 0,
39910
+ onPersistError: reportCacheWriteFailure
39593
39911
  });
39594
39912
  }
39595
39913
  /** Account metadata (no secrets) — useful for diagnostics. */
@@ -39627,7 +39945,7 @@ var CanvasClient = class {
39627
39945
  }
39628
39946
  if (destStat?.isDirectory()) throw new InvalidPathError(destinationPath);
39629
39947
  if (destStat && !opts.overwrite) throw new FileExistsError(destinationPath);
39630
- const parent = dirname(destinationPath);
39948
+ const parent = dirname2(destinationPath);
39631
39949
  try {
39632
39950
  await stat(parent);
39633
39951
  } catch {
@@ -40162,8 +40480,8 @@ function registerFileTools(server, client) {
40162
40480
  }
40163
40481
 
40164
40482
  // src/index.ts
40165
- var __dirname = dirname2(fileURLToPath(import.meta.url));
40166
- await loadDotenvSafely({ path: join3(__dirname, "..", ".env"), override: false });
40483
+ var __dirname = dirname3(fileURLToPath(import.meta.url));
40484
+ await loadDotenvSafely({ path: join4(__dirname, "..", ".env"), override: false });
40167
40485
  var resolved = null;
40168
40486
  var configError = null;
40169
40487
  try {
@@ -40173,7 +40491,7 @@ try {
40173
40491
  }
40174
40492
  await runMcp({
40175
40493
  name: "canvas",
40176
- version: "1.2.5",
40494
+ version: "1.3.0",
40177
40495
  // x-release-please-version
40178
40496
  tools: [
40179
40497
  (server) => {
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { writeFile, stat } from 'fs/promises';
2
+ import { createSessionCache, reportCacheWriteFailure } from './session-cache.js';
2
3
  import { dirname } from 'path';
3
4
  import { createOAuth2Refresher, parseLinkHeader } from '@chrischall/mcp-utils';
4
5
  import { CookieSessionManager } from '@chrischall/mcp-utils/session';
@@ -43,6 +44,8 @@ export class CanvasClient {
43
44
  // fall through as a Response and `mapStatus` turns them into a
44
45
  // TokenExpiredError.
45
46
  isExpired: (res) => res.status === 401 && this.canReauth(),
47
+ persistence: createSessionCache(account, { browserBacked: this.refreshSession !== null }) ?? undefined,
48
+ onPersistError: reportCacheWriteFailure,
46
49
  });
47
50
  }
48
51
  /** Account metadata (no secrets) — useful for diagnostics. */
package/dist/index.js CHANGED
@@ -46,7 +46,7 @@ catch (e) {
46
46
  // host's install-time `tools/list` succeeds.
47
47
  await runMcp({
48
48
  name: 'canvas',
49
- version: '1.2.5', // x-release-please-version
49
+ version: '1.3.0', // x-release-please-version
50
50
  tools: [
51
51
  (server) => {
52
52
  if (resolved) {
@@ -0,0 +1,91 @@
1
+ import { createFileStatePersistence, resolveStateFile, } from '@chrischall/mcp-utils/session';
2
+ import { parseBoolEnv } from '@chrischall/mcp-utils';
3
+ /** Where the signed-in session is cached between runs. */
4
+ export function sessionCachePath(env = process.env) {
5
+ return resolveStateFile({
6
+ env,
7
+ envVar: 'CANVAS_SESSION_FILE',
8
+ subdir: '.canvas-parent-mcp',
9
+ fileName: 'session.json',
10
+ });
11
+ }
12
+ /** Guard the stored envelope: real headers, and a login time. */
13
+ function isRecord(raw) {
14
+ if (raw === null || typeof raw !== 'object')
15
+ return false;
16
+ const r = raw;
17
+ if (typeof r.sessionAt !== 'number')
18
+ return false;
19
+ const s = r.session;
20
+ if (s === null || typeof s !== 'object')
21
+ return false;
22
+ const h = s.headers;
23
+ if (h === null || typeof h !== 'object' || Object.keys(h).length === 0)
24
+ return false;
25
+ if (Object.values(h).some((v) => typeof v !== 'string'))
26
+ return false;
27
+ return s.accessTokenExpiresAt === undefined || typeof s.accessTokenExpiresAt === 'number';
28
+ }
29
+ /**
30
+ * The credential a cached record is bound to, or `null` when this account has
31
+ * nothing worth caching.
32
+ *
33
+ * Mode decides, because what a login COSTS differs by mode:
34
+ *
35
+ * - `token` — the login builds an `Authorization` header from a static env
36
+ * token and makes no network call at all. Caching it would save nothing and
37
+ * put a bearer token on disk for no reason.
38
+ * - `session` with a fetchproxy lift — the cookie is re-read from a signed-in
39
+ * browser tab on every mint, which is both cheap and the point of that path.
40
+ * There is also no stored secret to bind to.
41
+ * - `session` with credentials — a real form login. Worth caching.
42
+ * - `oauth` — every login spends a refresh-token exchange against Canvas.
43
+ * Worth caching.
44
+ */
45
+ function bindingFor(account, browserBacked) {
46
+ if (account.mode === 'token')
47
+ return null;
48
+ if (account.mode === 'session') {
49
+ if (browserBacked)
50
+ return null;
51
+ return ['session', account.baseUrl, account.username.trim().toLowerCase(), account.password].join('\u0000');
52
+ }
53
+ // oauth: the refresh token and client identity are what a re-bootstrap changes.
54
+ return ['oauth', account.baseUrl, account.clientId, account.refreshToken].join('\u0000');
55
+ }
56
+ /**
57
+ * The session cache for this account, or `null` when caching is off, or when
58
+ * the mode has nothing worth caching (see {@link bindingFor}).
59
+ *
60
+ * The record is bound to the credentials that minted it — username+password for
61
+ * a session account, refresh token + client id for an oauth one — so rotating
62
+ * any of them discards it. Only a salted digest is written, never the values.
63
+ * The base URL is part of the binding too, so pointing the same credentials at
64
+ * a different Canvas instance does not reuse a session from the old one.
65
+ */
66
+ export function createSessionCache(account, opts = {}) {
67
+ const env = opts.env ?? process.env;
68
+ if (!parseBoolEnv('CANVAS_SESSION_CACHE', { env, default: true }))
69
+ return null;
70
+ const boundTo = bindingFor(account, opts.browserBacked === true);
71
+ if (boundTo === null)
72
+ return null;
73
+ return createFileStatePersistence({
74
+ filePath: sessionCachePath(env),
75
+ boundTo,
76
+ validate: (raw) => (isRecord(raw) ? raw : null),
77
+ });
78
+ }
79
+ /**
80
+ * Report a cache write that failed. Not fatal: the session is re-mintable from
81
+ * the credentials in the environment, so a lost write costs the next start a
82
+ * login rather than access. Canvas oauth refresh tokens are reusable — unlike a
83
+ * single-use rotating one, losing a write here cannot strand the account.
84
+ *
85
+ * stderr only; stdout is the JSON-RPC channel.
86
+ */
87
+ export function reportCacheWriteFailure(err) {
88
+ const detail = err instanceof Error ? err.message : String(err);
89
+ console.error(`[canvas-parent-mcp] could not cache the session (${detail}); continuing without the ` +
90
+ 'cache — every restart will authenticate again until this is fixed.');
91
+ }
package/mint.yaml ADDED
@@ -0,0 +1,84 @@
1
+ version: 1
2
+ name: Canvas Parent
3
+ slug: canvas-parent
4
+ summary: >-
5
+ Canvas LMS courses, assignments, grades and messages for a parent/observer.
6
+ The package ships TWO bins, so command.bin below is required — without it the
7
+ install refuses. A token or OAuth registration hosts on any tier; the
8
+ username/password and bridge paths need a browser and do not.
9
+ command:
10
+ # REQUIRED: package.json declares canvas-parent-mcp (the server) and
11
+ # canvas-parent-mcp-qr-login (a local CLI). Without this the install refuses.
12
+ bin: canvas-parent-mcp
13
+ env:
14
+ - name: CANVAS_BASE_URL
15
+ required: true
16
+ help: >-
17
+ Your district's Canvas host, e.g. https://cms.instructure.com. Must be an
18
+ https URL. Everything else is an auth mode on top of this.
19
+ - name: CANVAS_TOKEN
20
+ secret: true
21
+ required: false
22
+ help: >-
23
+ A Canvas personal access token (Account → Settings → New Access Token).
24
+ The simplest hosted auth mode; tried first.
25
+ - name: CANVAS_CLIENT_ID
26
+ required: false
27
+ help: OAuth client id. Use with CANVAS_CLIENT_SECRET + CANVAS_REFRESH_TOKEN.
28
+ - name: CANVAS_CLIENT_SECRET
29
+ secret: true
30
+ required: false
31
+ help: OAuth client secret. Use with CANVAS_CLIENT_ID + CANVAS_REFRESH_TOKEN.
32
+ - name: CANVAS_REFRESH_TOKEN
33
+ secret: true
34
+ required: false
35
+ help: OAuth refresh token. Use with CANVAS_CLIENT_ID + CANVAS_CLIENT_SECRET.
36
+ - name: CANVAS_USERNAME
37
+ required: false
38
+ help: >-
39
+ Canvas login email, for the session-scrape mode (direct Canvas accounts
40
+ only — not SSO). Prefer CANVAS_TOKEN when hosted.
41
+ - name: CANVAS_PASSWORD
42
+ secret: true
43
+ required: false
44
+ help: Canvas password, paired with CANVAS_USERNAME. Prefer CANVAS_TOKEN when hosted.
45
+ - name: CANVAS_NAME
46
+ required: false
47
+ help: Label for this district. Defaults to the host portion of CANVAS_BASE_URL.
48
+ - name: CANVAS_DISABLE_FETCHPROXY
49
+ required: false
50
+ help: >-
51
+ Set to 1 to disable the browser-bridge fallback, making missing
52
+ credentials a hard error. Recommended for a hosted registration, which has
53
+ no browser to fall back to.
54
+ - name: CANVAS_SESSION_CACHE
55
+ required: false
56
+ help: >-
57
+ Set to false to disable the on-disk session cache and authenticate on
58
+ every process start. Defaults to enabled.
59
+ - name: CANVAS_SESSION_FILE
60
+ required: false
61
+ help: >-
62
+ Absolute path for the session cache file. Defaults to
63
+ $MCP_DATA_DIR/.canvas-parent-mcp/session.json.
64
+ egress:
65
+ allow:
66
+ # Canvas is multi-tenant per district: the base URL is a *.instructure.com
67
+ # host (cms.instructure.com is the CMS district). Narrow this to YOUR
68
+ # district's host if you prefer.
69
+ #
70
+ # sso.canvaslms.com is deliberately NOT here: the hosted server never
71
+ # contacts it. session-login.ts only INSPECTS a redirect's Location for
72
+ # that host, and qr-login.ts is the separate canvas-parent-mcp-qr-login
73
+ # bin, not the child this manifest configures.
74
+ - instructure.com
75
+ - cms.instructure.com
76
+
77
+ state:
78
+ dataDir: true
79
+ reason: >-
80
+ Caches the signed-in session at
81
+ $MCP_DATA_DIR/.canvas-parent-mcp/session.json (0600). Without it every cold
82
+ start re-runs the form login (session mode) or spends a refresh-token
83
+ exchange against Canvas (oauth mode); a hosted child idles out after ten
84
+ minutes, so that is most starts. Token mode caches nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
- "version": "1.2.5",
3
+ "version": "1.3.0",
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>",
@@ -34,17 +34,20 @@
34
34
  ".claude-plugin",
35
35
  "skills",
36
36
  ".mcp.json",
37
- "server.json"
37
+ "server.json",
38
+ "mint.yaml"
38
39
  ],
39
40
  "scripts": {
40
41
  "build": "tsc && npm run bundle",
41
42
  "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
42
43
  "dev": "node --env-file=.env dist/index.js",
43
- "test": "vitest run",
44
- "test:watch": "vitest"
44
+ "test": "npm run typecheck && vitest run",
45
+ "test:coverage": "npm run typecheck && vitest run --coverage",
46
+ "test:watch": "vitest",
47
+ "typecheck": "tsc -p tsconfig.json --noEmit"
45
48
  },
46
49
  "dependencies": {
47
- "@chrischall/mcp-utils": "^0.14.0",
50
+ "@chrischall/mcp-utils": "^0.17.1",
48
51
  "@fetchproxy/bootstrap": "^2.0.0",
49
52
  "@fetchproxy/server": "^2.0.0",
50
53
  "@modelcontextprotocol/sdk": "^1.29.0",
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": "1.2.5",
9
+ "version": "1.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "1.2.5",
14
+ "version": "1.3.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -67,6 +67,18 @@
67
67
  "description": "Friendly name for the Canvas instance (defaults to the host portion of the base URL)",
68
68
  "isRequired": false,
69
69
  "format": "string"
70
+ },
71
+ {
72
+ "name": "CANVAS_SESSION_CACHE",
73
+ "description": "Set to false to disable the on-disk session cache and re-authenticate on every process start. Defaults to enabled.",
74
+ "isRequired": false,
75
+ "format": "string"
76
+ },
77
+ {
78
+ "name": "CANVAS_SESSION_FILE",
79
+ "description": "Absolute path for the session cache file. Defaults to $MCP_DATA_DIR/.canvas-parent-mcp/session.json.",
80
+ "isRequired": false,
81
+ "format": "string"
70
82
  }
71
83
  ]
72
84
  }
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: canvas-parent-mcp
2
+ name: canvas-parent
3
3
  description: This skill should be used when the user asks about Canvas LMS data — their own student account or any observed student. Triggers on phrases like "check Canvas", "what's my grade", "Canvas inbox", "what's due", "missing assignments", "Canvas LMS", "Instructure", "course announcements", "syllabus", or any request about courses, assignments, grades, conversations, announcements, planner items, or files in Canvas.
4
4
  ---
5
5