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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/dist/bundle.js +278 -135
- package/dist/client.js +129 -10
- package/dist/index.js +1 -1
- package/dist/tools/conversations.js +5 -1
- package/dist/tools/files.js +4 -4
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/canvas-parent/SKILL.md +1 -1
- package/skills/canvas-parent-api/references/canvas-endpoints.md +3 -1
package/dist/client.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
132
|
+
await writeConfined(dest, destinationPath, buf, opts.overwrite === true);
|
|
108
133
|
return {
|
|
109
|
-
path:
|
|
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
|
|
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.
|
|
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/dist/tools/files.js
CHANGED
|
@@ -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 (
|
|
10
|
-
destinationPath: z.string().describe('
|
|
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.
|
|
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.
|
|
9
|
+
"version": "2.1.4",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "canvas-parent-mcp",
|
|
14
|
-
"version": "2.1.
|
|
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
|
|
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`).
|