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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +427 -109
- package/dist/client.js +3 -0
- package/dist/index.js +1 -1
- package/dist/session-cache.js +91 -0
- package/mint.yaml +84 -0
- package/package.json +8 -5
- package/server.json +14 -2
- package/skills/canvas-parent/SKILL.md +1 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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((
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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(
|
|
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((
|
|
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((
|
|
34673
|
+
return new Promise((resolve2) => {
|
|
34674
34674
|
const json2 = serializeMessage(message);
|
|
34675
34675
|
if (this._stdout.write(json2)) {
|
|
34676
|
-
|
|
34676
|
+
resolve2();
|
|
34677
34677
|
} else {
|
|
34678
|
-
this._stdout.once("drain",
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
36565
|
-
resolveOwnSession =
|
|
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((
|
|
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(() =>
|
|
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((
|
|
36825
|
-
ws.once("open", () =>
|
|
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((
|
|
36856
|
-
resolveFirstReady =
|
|
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((
|
|
37735
|
-
this.pending.set(id,
|
|
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((
|
|
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
|
-
|
|
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((
|
|
38114
|
-
this.pendingReadCookies.set(id,
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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((
|
|
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.
|
|
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:
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
40166
|
-
await loadDotenvSafely({ path:
|
|
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.
|
|
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
|
@@ -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.
|
|
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:
|
|
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.
|
|
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.
|
|
9
|
+
"version": "1.3.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "canvas-parent-mcp",
|
|
14
|
-
"version": "1.
|
|
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
|
|
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
|
|