canvas-parent-mcp 2.1.3 → 2.1.4

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.3"
10
+ "version": "2.1.4"
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.3",
18
+ "version": "2.1.4",
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.3",
4
+ "version": "2.1.4",
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,7 +29,7 @@ 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.
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. It never writes through a symlink at the destination, and new files are created owner-only (0600).
33
33
 
34
34
  ## Configuration
35
35
 
package/dist/bundle.js CHANGED
@@ -5007,7 +5007,7 @@ var require_memoizer = __commonJS({
5007
5007
  return bucket;
5008
5008
  }
5009
5009
  var handoff2;
5010
- var open2 = [];
5010
+ var open3 = [];
5011
5011
  var memo3 = {
5012
5012
  alloc(_inst, payload, empty) {
5013
5013
  const bucket = handoff2;
@@ -5016,7 +5016,7 @@ var require_memoizer = __commonJS({
5016
5016
  handoff2 = void 0;
5017
5017
  const entry = { value: empty, issues: null };
5018
5018
  bucket.set(payload.value, entry);
5019
- open2.push(entry);
5019
+ open3.push(entry);
5020
5020
  return empty;
5021
5021
  },
5022
5022
  guard(inst) {
@@ -5087,10 +5087,10 @@ var require_memoizer = __commonJS({
5087
5087
  return payload;
5088
5088
  }
5089
5089
  handoff2 = bucket;
5090
- const depth = open2.length;
5090
+ const depth = open3.length;
5091
5091
  const result = base(payload, ctx);
5092
5092
  handoff2 = void 0;
5093
- const entry = open2.length > depth ? open2.pop() : void 0;
5093
+ const entry = open3.length > depth ? open3.pop() : void 0;
5094
5094
  if (result instanceof Promise) {
5095
5095
  return result.then((r) => {
5096
5096
  if (entry)
@@ -26497,7 +26497,7 @@ var require_stream = __commonJS({
26497
26497
  };
26498
26498
  duplex._final = function(callback) {
26499
26499
  if (ws.readyState === ws.CONNECTING) {
26500
- ws.once("open", function open2() {
26500
+ ws.once("open", function open3() {
26501
26501
  duplex._final(callback);
26502
26502
  });
26503
26503
  return;
@@ -26518,7 +26518,7 @@ var require_stream = __commonJS({
26518
26518
  };
26519
26519
  duplex._write = function(chunk2, encoding, callback) {
26520
26520
  if (ws.readyState === ws.CONNECTING) {
26521
- ws.once("open", function open2() {
26521
+ ws.once("open", function open3() {
26522
26522
  duplex._write(chunk2, encoding, callback);
26523
26523
  });
26524
26524
  return;
@@ -67581,7 +67581,7 @@ function loadAccount(env = process.env) {
67581
67581
  // package.json
67582
67582
  var package_default = {
67583
67583
  name: "canvas-parent-mcp",
67584
- version: "2.1.3",
67584
+ version: "2.1.4",
67585
67585
  mcpName: "io.github.chrischall/canvas-parent-mcp",
67586
67586
  description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
67587
67587
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -67887,7 +67887,8 @@ function registerCredentialHealthcheckTool(args) {
67887
67887
  }
67888
67888
 
67889
67889
  // src/client.ts
67890
- import { writeFile as writeFile3, stat } from "fs/promises";
67890
+ import { constants as fsConstants } from "fs";
67891
+ import { lstat as lstat2, open as open2, stat } from "fs/promises";
67891
67892
 
67892
67893
  // node_modules/@chrischall/mcp-utils/dist/session/index.js
67893
67894
  import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
@@ -68466,9 +68467,10 @@ var CanvasClient = class {
68466
68467
  const dest = this.confineDestination(destinationPath);
68467
68468
  let destStat = null;
68468
68469
  try {
68469
- destStat = await stat(dest);
68470
+ destStat = await lstat2(dest);
68470
68471
  } catch {
68471
68472
  }
68473
+ if (destStat?.isSymbolicLink()) throw new InvalidPathError(destinationPath, "is a symlink");
68472
68474
  if (destStat?.isDirectory()) throw new InvalidPathError(destinationPath);
68473
68475
  if (destStat && !opts.overwrite) throw new FileExistsError(destinationPath);
68474
68476
  const parent = dirname3(dest);
@@ -68482,7 +68484,7 @@ var CanvasClient = class {
68482
68484
  if (res.status === 404) throw new Error(`Canvas download 404 for ${path}`);
68483
68485
  if (!res.ok) throw new Error(`Canvas download ${res.status} for ${path}`);
68484
68486
  const buf = new Uint8Array(await res.arrayBuffer());
68485
- await writeFile3(dest, buf);
68487
+ await writeConfined(dest, destinationPath, buf, opts.overwrite === true);
68486
68488
  return {
68487
68489
  path: dest,
68488
68490
  bytes: buf.byteLength,
@@ -68678,9 +68680,28 @@ var DownloadDestinationRejectedError = class extends Error {
68678
68680
  path;
68679
68681
  root;
68680
68682
  };
68683
+ async function writeConfined(dest, requested, buf, overwrite) {
68684
+ const { O_WRONLY, O_CREAT, O_TRUNC, O_EXCL, O_NOFOLLOW } = fsConstants;
68685
+ const flags = O_WRONLY | O_CREAT | O_NOFOLLOW | (overwrite ? O_TRUNC : O_EXCL);
68686
+ let handle;
68687
+ try {
68688
+ handle = await open2(dest, flags, 384);
68689
+ } catch (e) {
68690
+ const code = e.code;
68691
+ if (code === "ELOOP") throw new InvalidPathError(requested, "is a symlink");
68692
+ if (code === "EEXIST") throw new FileExistsError(requested);
68693
+ if (code === "EISDIR") throw new InvalidPathError(requested);
68694
+ throw e;
68695
+ }
68696
+ try {
68697
+ await handle.writeFile(buf);
68698
+ } finally {
68699
+ await handle.close();
68700
+ }
68701
+ }
68681
68702
  var InvalidPathError = class extends Error {
68682
- constructor(path) {
68683
- super(`InvalidPath: destinationPath must be a filename, not a directory: ${path}`);
68703
+ constructor(path, reason = "must be a filename, not a directory") {
68704
+ super(`InvalidPath: destinationPath ${reason}: ${path}`);
68684
68705
  this.path = path;
68685
68706
  this.name = "InvalidPathError";
68686
68707
  }
@@ -69041,12 +69062,13 @@ function registerConversationTools(server, client) {
69041
69062
  return viewResponse(args.view, data);
69042
69063
  });
69043
69064
  server.registerTool("canvas_get_conversation", {
69044
- description: "Get a full Canvas conversation thread with all messages.",
69065
+ description: "Get a full Canvas conversation thread with all messages. Read-only: does not mark the conversation as read.",
69045
69066
  annotations: { readOnlyHint: true },
69046
69067
  inputSchema: getArgs3
69047
69068
  }, async (rawArgs) => {
69048
69069
  const args = getArgs3.parse(rawArgs);
69049
69070
  const path = buildPath(`/api/v1/conversations/${encodeURIComponent(args.id)}`, {
69071
+ auto_mark_as_read: false,
69050
69072
  "include[]": ["participant_avatars"]
69051
69073
  });
69052
69074
  const data = await client.request(path);
@@ -69127,7 +69149,7 @@ try {
69127
69149
  }
69128
69150
  await runMcp({
69129
69151
  name: "canvas",
69130
- version: "2.1.3",
69152
+ version: "2.1.4",
69131
69153
  // x-release-please-version
69132
69154
  tools: [
69133
69155
  // Outside the `if (resolved)` on purpose: an unconfigured server must
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
- import { writeFile, stat } from 'fs/promises';
1
+ import { constants as fsConstants } from 'fs';
2
+ import { lstat, open, stat } from 'fs/promises';
2
3
  import { createSessionCache, reportCacheWriteFailure } from './session-cache.js';
3
4
  import { dirname, isAbsolute, join, resolve } from 'path';
4
5
  import { homedir } from 'os';
@@ -100,11 +101,15 @@ export class CanvasClient {
100
101
  async download(path, destinationPath, opts = {}) {
101
102
  const url = this.pinDownloadUrl(path);
102
103
  const dest = this.confineDestination(destinationPath);
104
+ // lstat, not stat: a symlink at the final component is refused outright
105
+ // rather than judged by whatever it points at.
103
106
  let destStat = null;
104
107
  try {
105
- destStat = await stat(dest);
108
+ destStat = await lstat(dest);
106
109
  }
107
110
  catch { /* not present, ok */ }
111
+ if (destStat?.isSymbolicLink())
112
+ throw new InvalidPathError(destinationPath, 'is a symlink');
108
113
  if (destStat?.isDirectory())
109
114
  throw new InvalidPathError(destinationPath);
110
115
  if (destStat && !opts.overwrite)
@@ -124,7 +129,7 @@ export class CanvasClient {
124
129
  if (!res.ok)
125
130
  throw new Error(`Canvas download ${res.status} for ${path}`);
126
131
  const buf = new Uint8Array(await res.arrayBuffer());
127
- await writeFile(dest, buf);
132
+ await writeConfined(dest, destinationPath, buf, opts.overwrite === true);
128
133
  return {
129
134
  path: dest,
130
135
  bytes: buf.byteLength,
@@ -349,10 +354,44 @@ export class DownloadDestinationRejectedError extends Error {
349
354
  this.name = 'DownloadDestinationRejectedError';
350
355
  }
351
356
  }
357
+ /**
358
+ * Write a download to its (already confined) destination without ever
359
+ * following a symlink at the final path component (fleet-audit#925). The
360
+ * pre-flight lstat in download() is separated from this write by a network
361
+ * fetch, so a link can be planted in between; O_NOFOLLOW makes the open itself
362
+ * refuse it (ELOOP), and O_EXCL (without overwrite) refuses anything that
363
+ * appeared at all. New files are created owner-only (0600): they are a
364
+ * student's school records.
365
+ */
366
+ async function writeConfined(dest, requested, buf, overwrite) {
367
+ // O_NOFOLLOW is POSIX-only: on Windows it is undefined, which `|` coerces to 0.
368
+ const { O_WRONLY, O_CREAT, O_TRUNC, O_EXCL, O_NOFOLLOW } = fsConstants;
369
+ const flags = O_WRONLY | O_CREAT | O_NOFOLLOW | (overwrite ? O_TRUNC : O_EXCL);
370
+ let handle;
371
+ try {
372
+ handle = await open(dest, flags, 0o600);
373
+ }
374
+ catch (e) {
375
+ const code = e.code;
376
+ if (code === 'ELOOP')
377
+ throw new InvalidPathError(requested, 'is a symlink');
378
+ if (code === 'EEXIST')
379
+ throw new FileExistsError(requested);
380
+ if (code === 'EISDIR')
381
+ throw new InvalidPathError(requested);
382
+ throw e;
383
+ }
384
+ try {
385
+ await handle.writeFile(buf);
386
+ }
387
+ finally {
388
+ await handle.close();
389
+ }
390
+ }
352
391
  export class InvalidPathError extends Error {
353
392
  path;
354
- constructor(path) {
355
- super(`InvalidPath: destinationPath must be a filename, not a directory: ${path}`);
393
+ constructor(path, reason = 'must be a filename, not a directory') {
394
+ super(`InvalidPath: destinationPath ${reason}: ${path}`);
356
395
  this.path = path;
357
396
  this.name = 'InvalidPathError';
358
397
  }
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.3', // x-release-please-version
50
+ version: '2.1.4', // 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).
@@ -26,12 +26,16 @@ export function registerConversationTools(server, client) {
26
26
  return viewResponse(args.view, data);
27
27
  });
28
28
  server.registerTool('canvas_get_conversation', {
29
- description: 'Get a full Canvas conversation thread with all messages.',
29
+ description: 'Get a full Canvas conversation thread with all messages. Read-only: does not mark the conversation as read.',
30
30
  annotations: { readOnlyHint: true },
31
31
  inputSchema: getArgs,
32
32
  }, async (rawArgs) => {
33
33
  const args = getArgs.parse(rawArgs);
34
+ // Canvas marks a conversation read on GET unless told otherwise; pin it
35
+ // off so this tool honours its readOnlyHint and never sends the parent's
36
+ // read receipt to the teacher on a model's behalf (fleet-audit#926).
34
37
  const path = buildPath(`/api/v1/conversations/${encodeURIComponent(args.id)}`, {
38
+ auto_mark_as_read: false,
35
39
  'include[]': ['participant_avatars'],
36
40
  });
37
41
  const data = await client.request(path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
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.3",
9
+ "version": "2.1.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "2.1.3",
14
+ "version": "2.1.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },