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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/bundle.js +36 -14
- package/dist/client.js +44 -5
- package/dist/index.js +1 -1
- package/dist/tools/conversations.js +5 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
5090
|
+
const depth = open3.length;
|
|
5091
5091
|
const result = base(payload, ctx);
|
|
5092
5092
|
handoff2 = void 0;
|
|
5093
|
-
const entry =
|
|
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
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
"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
|
},
|