canvas-parent-mcp 2.1.2 → 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.
package/dist/client.js CHANGED
@@ -1,7 +1,9 @@
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
- import { dirname } from 'path';
4
- import { createOAuth2Refresher, parseLinkHeader } from '@chrischall/mcp-utils';
4
+ import { dirname, isAbsolute, join, resolve } from 'path';
5
+ import { homedir } from 'os';
6
+ import { assertPathWithinRoots, createOAuth2Refresher, expandPath, parseLinkHeader, readEnvVar, } from '@chrischall/mcp-utils';
5
7
  import { CookieSessionManager } from '@chrischall/mcp-utils/session';
6
8
  import { sessionLogin as defaultSessionLogin } from './session-login.js';
7
9
  // Re-export the fleet-shared RFC 5988 Link parser so existing importers
@@ -19,6 +21,8 @@ export class CanvasClient {
19
21
  * what keeps the path alive once the browser cookie lapses.
20
22
  */
21
23
  refreshSession;
24
+ /** Explicit download root; when unset, resolved per call (see {@link downloadRoot}). */
25
+ outputDir;
22
26
  auth;
23
27
  /** Lazily-built shared refresh_token exchanger (oauth mode only). */
24
28
  oauthRefresh = null;
@@ -34,6 +38,7 @@ export class CanvasClient {
34
38
  */
35
39
  constructor(account, opts = {}) {
36
40
  this.account = account;
41
+ this.outputDir = opts.outputDir;
37
42
  this.sessionLoginFn = opts.sessionLogin ?? defaultSessionLogin;
38
43
  this.refreshSession = opts.refreshSession ?? null;
39
44
  this.auth = new CookieSessionManager({
@@ -78,24 +83,44 @@ export class CanvasClient {
78
83
  }
79
84
  return out;
80
85
  }
86
+ /**
87
+ * Download a Canvas file to disk.
88
+ *
89
+ * Both inputs arrive from tool arguments, which a prompt injection in
90
+ * classmate/teacher-authored Canvas content can steer, so both are pinned
91
+ * BEFORE any credential is attached:
92
+ * - `path` must resolve to the configured Canvas origin (https, same host —
93
+ * the `https://canvas@evil/` userinfo trick parses to a foreign host and is
94
+ * refused) and name a `/files/<id>` resource. Only the initial request is
95
+ * pinned: Canvas legitimately redirects to its file store, and fetch
96
+ * strips Authorization/Cookie on a cross-origin redirect.
97
+ * - `destinationPath` must lie inside the download root (see
98
+ * {@link downloadRoot}), checked through symlinks; a relative path is
99
+ * resolved against that root.
100
+ */
81
101
  async download(path, destinationPath, opts = {}) {
102
+ const url = this.pinDownloadUrl(path);
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.
82
106
  let destStat = null;
83
107
  try {
84
- destStat = await stat(destinationPath);
108
+ destStat = await lstat(dest);
85
109
  }
86
110
  catch { /* not present, ok */ }
111
+ if (destStat?.isSymbolicLink())
112
+ throw new InvalidPathError(destinationPath, 'is a symlink');
87
113
  if (destStat?.isDirectory())
88
114
  throw new InvalidPathError(destinationPath);
89
115
  if (destStat && !opts.overwrite)
90
116
  throw new FileExistsError(destinationPath);
91
- const parent = dirname(destinationPath);
117
+ const parent = dirname(dest);
92
118
  try {
93
119
  await stat(parent);
94
120
  }
95
121
  catch {
96
122
  throw new ParentDirectoryMissingError(parent);
97
123
  }
98
- const url = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
99
124
  const res = await this.authedFetch(url, {});
100
125
  if (res.status === 401)
101
126
  throw new TokenExpiredError(this.account.mode);
@@ -104,13 +129,54 @@ export class CanvasClient {
104
129
  if (!res.ok)
105
130
  throw new Error(`Canvas download ${res.status} for ${path}`);
106
131
  const buf = new Uint8Array(await res.arrayBuffer());
107
- await writeFile(destinationPath, buf);
132
+ await writeConfined(dest, destinationPath, buf, opts.overwrite === true);
108
133
  return {
109
- path: destinationPath,
134
+ path: dest,
110
135
  bytes: buf.byteLength,
111
136
  contentType: res.headers.get('content-type') ?? 'application/octet-stream',
112
137
  };
113
138
  }
139
+ /**
140
+ * The directory downloads are confined to: the constructor's `outputDir`,
141
+ * else `CANVAS_OUTPUT_DIR`, else `~/Downloads`. Read per call so an env
142
+ * change needs no client rebuild.
143
+ */
144
+ downloadRoot() {
145
+ return expandPath(this.outputDir ?? readEnvVar('CANVAS_OUTPUT_DIR') ?? join(homedir(), 'Downloads'));
146
+ }
147
+ confineDestination(destinationPath) {
148
+ const root = this.downloadRoot();
149
+ // `~` expands to home; any other relative path is anchored at the root
150
+ // (not the process cwd, which for a desktop-launched server is often `/`).
151
+ const candidate = destinationPath.startsWith('~') || isAbsolute(destinationPath)
152
+ ? expandPath(destinationPath)
153
+ : resolve(root, destinationPath);
154
+ try {
155
+ assertPathWithinRoots(candidate, [root]);
156
+ }
157
+ catch {
158
+ throw new DownloadDestinationRejectedError(destinationPath, root);
159
+ }
160
+ return candidate;
161
+ }
162
+ pinDownloadUrl(path) {
163
+ const raw = /^[a-z][a-z0-9+.-]*:/i.test(path) ? path : `${this.account.baseUrl}${path}`;
164
+ let parsed;
165
+ try {
166
+ parsed = new URL(raw);
167
+ }
168
+ catch {
169
+ throw new DownloadUrlRejectedError(path, 'not a valid URL');
170
+ }
171
+ const origin = new URL(this.account.baseUrl).origin;
172
+ if (parsed.protocol !== 'https:' || parsed.origin !== origin || parsed.username || parsed.password) {
173
+ throw new DownloadUrlRejectedError(path, `must be an https URL on ${origin}`);
174
+ }
175
+ if (!/\/files\/[^/]+/.test(parsed.pathname)) {
176
+ throw new DownloadUrlRejectedError(path, 'must name a Canvas file (a /files/<id> path)');
177
+ }
178
+ return parsed.href;
179
+ }
114
180
  async doRawRequest(path, opts) {
115
181
  const url = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
116
182
  const accept = opts.responseType === 'text'
@@ -269,10 +335,63 @@ export class CanvasUnreachableError extends Error {
269
335
  this.name = 'CanvasUnreachableError';
270
336
  }
271
337
  }
338
+ export class DownloadUrlRejectedError extends Error {
339
+ url;
340
+ constructor(url, reason) {
341
+ super(`DownloadUrlRejected: ${reason}; use the \`url\` from canvas_list_course_files. Got: ${url}`);
342
+ this.url = url;
343
+ this.name = 'DownloadUrlRejectedError';
344
+ }
345
+ }
346
+ export class DownloadDestinationRejectedError extends Error {
347
+ path;
348
+ root;
349
+ constructor(path, root) {
350
+ super(`DownloadDestinationRejected: destinationPath must be inside ${root} ` +
351
+ `(set CANVAS_OUTPUT_DIR to change it). Got: ${path}`);
352
+ this.path = path;
353
+ this.root = root;
354
+ this.name = 'DownloadDestinationRejectedError';
355
+ }
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
+ }
272
391
  export class InvalidPathError extends Error {
273
392
  path;
274
- constructor(path) {
275
- 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}`);
276
395
  this.path = path;
277
396
  this.name = 'InvalidPathError';
278
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.2', // 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);
@@ -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.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.2",
9
+ "version": "2.1.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "2.1.2",
14
+ "version": "2.1.4",
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`).