canvas-parent-mcp 1.1.7 → 1.1.8
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 +269 -66
- package/dist/client.js +28 -21
- package/dist/index.js +1 -1
- package/dist/session-login.js +24 -32
- package/dist/tools/_shared.js +2 -14
- package/package.json +2 -2
- 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": "1.1.
|
|
10
|
+
"version": "1.1.8"
|
|
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.1.
|
|
18
|
+
"version": "1.1.8",
|
|
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.1.
|
|
4
|
+
"version": "1.1.8",
|
|
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
|
@@ -34691,6 +34691,20 @@ async function runMcp(opts) {
|
|
|
34691
34691
|
|
|
34692
34692
|
// node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
34693
34693
|
var DEFAULT_ERROR_MESSAGE_MAX = 500;
|
|
34694
|
+
var McpToolError = class extends Error {
|
|
34695
|
+
/** Actionable remediation text, when one applies. */
|
|
34696
|
+
hint;
|
|
34697
|
+
constructor(message, opts) {
|
|
34698
|
+
super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
|
|
34699
|
+
this.name = "McpToolError";
|
|
34700
|
+
if (opts?.hint !== void 0)
|
|
34701
|
+
this.hint = opts.hint;
|
|
34702
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
34703
|
+
}
|
|
34704
|
+
};
|
|
34705
|
+
function createHelpfulError(message, opts) {
|
|
34706
|
+
return new McpToolError(message, opts);
|
|
34707
|
+
}
|
|
34694
34708
|
var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
34695
34709
|
var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
34696
34710
|
var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
|
|
@@ -34711,8 +34725,12 @@ var API_KEY_RE = new RegExp([
|
|
|
34711
34725
|
// webhook signing secret (Stripe-style)
|
|
34712
34726
|
].map((p) => `\\b${p}`).join("|"), "g");
|
|
34713
34727
|
var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
|
|
34728
|
+
var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
|
|
34729
|
+
var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
|
|
34730
|
+
var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
|
|
34731
|
+
var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
|
|
34714
34732
|
function redactSecrets(text) {
|
|
34715
|
-
return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
|
|
34733
|
+
return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
|
|
34716
34734
|
}
|
|
34717
34735
|
function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
|
|
34718
34736
|
const str = text === null || text === void 0 ? "" : String(text);
|
|
@@ -34760,6 +34778,23 @@ async function loadDotenvSafely(opts = {}) {
|
|
|
34760
34778
|
}
|
|
34761
34779
|
|
|
34762
34780
|
// node_modules/@chrischall/mcp-utils/dist/http/index.js
|
|
34781
|
+
function buildQueryString(params) {
|
|
34782
|
+
const parts = [];
|
|
34783
|
+
for (const [key, value] of Object.entries(params)) {
|
|
34784
|
+
if (value === void 0 || value === null || value === "")
|
|
34785
|
+
continue;
|
|
34786
|
+
if (Array.isArray(value)) {
|
|
34787
|
+
for (const item of value) {
|
|
34788
|
+
if (item === void 0 || item === null || item === "")
|
|
34789
|
+
continue;
|
|
34790
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`);
|
|
34791
|
+
}
|
|
34792
|
+
} else {
|
|
34793
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
34794
|
+
}
|
|
34795
|
+
}
|
|
34796
|
+
return parts.length > 0 ? `?${parts.join("&")}` : "";
|
|
34797
|
+
}
|
|
34763
34798
|
function parseLinkHeader(header) {
|
|
34764
34799
|
const out = {};
|
|
34765
34800
|
if (!header)
|
|
@@ -34771,6 +34806,90 @@ function parseLinkHeader(header) {
|
|
|
34771
34806
|
}
|
|
34772
34807
|
return out;
|
|
34773
34808
|
}
|
|
34809
|
+
var MAX_AGE_RE = /(?:^|;)\s*Max-Age\s*=\s*(-?\d+)\s*(?:;|$)/i;
|
|
34810
|
+
var EXPIRES_RE = /(?:^|;)\s*Expires\s*=\s*([^;]+)/i;
|
|
34811
|
+
var EXPIRES_DELETION_YEAR = 2e3;
|
|
34812
|
+
function isDeletionCookie(entry) {
|
|
34813
|
+
const maxAge = MAX_AGE_RE.exec(entry);
|
|
34814
|
+
if (maxAge)
|
|
34815
|
+
return Number(maxAge[1]) <= 0;
|
|
34816
|
+
const expires = EXPIRES_RE.exec(entry);
|
|
34817
|
+
if (expires && expires[1]) {
|
|
34818
|
+
const when = new Date(expires[1].trim());
|
|
34819
|
+
if (!Number.isNaN(when.getTime()))
|
|
34820
|
+
return when.getUTCFullYear() < EXPIRES_DELETION_YEAR;
|
|
34821
|
+
}
|
|
34822
|
+
return false;
|
|
34823
|
+
}
|
|
34824
|
+
function parseNameValue(entry) {
|
|
34825
|
+
const nameValue = (entry.split(";")[0] ?? "").trim();
|
|
34826
|
+
const eqIdx = nameValue.indexOf("=");
|
|
34827
|
+
if (eqIdx < 1)
|
|
34828
|
+
return null;
|
|
34829
|
+
return { name: nameValue.slice(0, eqIdx).trim(), value: nameValue.slice(eqIdx + 1).trim() };
|
|
34830
|
+
}
|
|
34831
|
+
function parseCookieJar(setCookieHeaders) {
|
|
34832
|
+
const entries = setCookieEntries(setCookieHeaders);
|
|
34833
|
+
const jar = /* @__PURE__ */ new Map();
|
|
34834
|
+
for (const entry of entries) {
|
|
34835
|
+
if (isDeletionCookie(entry))
|
|
34836
|
+
continue;
|
|
34837
|
+
const pair = parseNameValue(entry);
|
|
34838
|
+
if (!pair || !pair.value)
|
|
34839
|
+
continue;
|
|
34840
|
+
jar.set(pair.name, pair.value);
|
|
34841
|
+
}
|
|
34842
|
+
const cookies = {};
|
|
34843
|
+
for (const [k, v] of jar)
|
|
34844
|
+
cookies[k] = v;
|
|
34845
|
+
const cookieHeader = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
34846
|
+
return { cookies, cookieHeader };
|
|
34847
|
+
}
|
|
34848
|
+
function setCookieEntries(source) {
|
|
34849
|
+
if (source == null)
|
|
34850
|
+
return [];
|
|
34851
|
+
if (Array.isArray(source))
|
|
34852
|
+
return source;
|
|
34853
|
+
if (typeof source === "string")
|
|
34854
|
+
return splitSetCookie(source);
|
|
34855
|
+
if (typeof source.getSetCookie === "function")
|
|
34856
|
+
return source.getSetCookie();
|
|
34857
|
+
const joined = source.get("set-cookie");
|
|
34858
|
+
return joined ? splitSetCookie(joined) : [];
|
|
34859
|
+
}
|
|
34860
|
+
var CookieJar = class {
|
|
34861
|
+
jar = /* @__PURE__ */ new Map();
|
|
34862
|
+
/** Merge `Set-Cookie`s into the jar (later wins; deletion markers remove). */
|
|
34863
|
+
absorb(setCookies) {
|
|
34864
|
+
for (const entry of setCookieEntries(setCookies)) {
|
|
34865
|
+
const pair = parseNameValue(entry);
|
|
34866
|
+
if (!pair)
|
|
34867
|
+
continue;
|
|
34868
|
+
if (isDeletionCookie(entry)) {
|
|
34869
|
+
this.jar.delete(pair.name);
|
|
34870
|
+
continue;
|
|
34871
|
+
}
|
|
34872
|
+
if (!pair.value)
|
|
34873
|
+
continue;
|
|
34874
|
+
this.jar.set(pair.name, pair.value);
|
|
34875
|
+
}
|
|
34876
|
+
}
|
|
34877
|
+
/** The current value of a cookie, or `undefined` when absent/deleted. */
|
|
34878
|
+
get(name) {
|
|
34879
|
+
return this.jar.get(name);
|
|
34880
|
+
}
|
|
34881
|
+
/** Render the `Cookie` request-header value: `name=value; name2=value2`. */
|
|
34882
|
+
header() {
|
|
34883
|
+
return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
34884
|
+
}
|
|
34885
|
+
/** Number of cookies currently in the jar. */
|
|
34886
|
+
get size() {
|
|
34887
|
+
return this.jar.size;
|
|
34888
|
+
}
|
|
34889
|
+
};
|
|
34890
|
+
function splitSetCookie(header) {
|
|
34891
|
+
return header.split(/,(?=\s*[^;,\s]+\s*=)/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
34892
|
+
}
|
|
34774
34893
|
|
|
34775
34894
|
// node_modules/@chrischall/mcp-utils/dist/zod/index.js
|
|
34776
34895
|
var PositiveInt = external_exports.number().int().positive();
|
|
@@ -34791,6 +34910,74 @@ var pageSchema = {
|
|
|
34791
34910
|
page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
|
|
34792
34911
|
};
|
|
34793
34912
|
|
|
34913
|
+
// node_modules/@chrischall/mcp-utils/dist/auth/index.js
|
|
34914
|
+
var sleep = (ms) => ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
|
|
34915
|
+
function createOAuth2Refresher(opts) {
|
|
34916
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
34917
|
+
const grantType = opts.grantType ?? "refresh_token";
|
|
34918
|
+
const maxRetries = opts.retry?.count ?? 0;
|
|
34919
|
+
const retryDelayMs = opts.retry?.delayMs ?? 0;
|
|
34920
|
+
let inFlight = null;
|
|
34921
|
+
async function exchangeOnce() {
|
|
34922
|
+
const body = new URLSearchParams({
|
|
34923
|
+
grant_type: grantType,
|
|
34924
|
+
refresh_token: opts.refreshToken,
|
|
34925
|
+
...opts.params
|
|
34926
|
+
}).toString();
|
|
34927
|
+
const res = await doFetch(opts.endpoint, {
|
|
34928
|
+
method: "POST",
|
|
34929
|
+
headers: {
|
|
34930
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
34931
|
+
Accept: "application/json"
|
|
34932
|
+
},
|
|
34933
|
+
body
|
|
34934
|
+
});
|
|
34935
|
+
if (!res.ok) {
|
|
34936
|
+
const errText = await res.text().catch(() => "");
|
|
34937
|
+
throw createHelpfulError(`OAuth2 token refresh failed: ${res.status} ${res.statusText}: ${truncateErrorMessage(errText, 200)}`, { hint: "The refresh token may be expired or revoked \u2014 re-authenticate." });
|
|
34938
|
+
}
|
|
34939
|
+
const data = await res.json().catch(() => null);
|
|
34940
|
+
const accessToken = data?.access_token;
|
|
34941
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) {
|
|
34942
|
+
throw createHelpfulError("OAuth2 token refresh returned no access_token.", {
|
|
34943
|
+
hint: "The token endpoint returned an unexpected body."
|
|
34944
|
+
});
|
|
34945
|
+
}
|
|
34946
|
+
const result = { accessToken };
|
|
34947
|
+
if (typeof data?.refresh_token === "string" && data.refresh_token.length > 0) {
|
|
34948
|
+
result.refreshToken = data.refresh_token;
|
|
34949
|
+
}
|
|
34950
|
+
if (typeof data?.expires_in === "number") {
|
|
34951
|
+
result.expiresIn = data.expires_in;
|
|
34952
|
+
result.expiresAt = new Date(Date.now() + data.expires_in * 1e3);
|
|
34953
|
+
}
|
|
34954
|
+
return result;
|
|
34955
|
+
}
|
|
34956
|
+
async function exchangeWithRetry() {
|
|
34957
|
+
let lastErr;
|
|
34958
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
34959
|
+
try {
|
|
34960
|
+
return await exchangeOnce();
|
|
34961
|
+
} catch (e) {
|
|
34962
|
+
lastErr = e;
|
|
34963
|
+
if (attempt < maxRetries)
|
|
34964
|
+
await sleep(retryDelayMs);
|
|
34965
|
+
}
|
|
34966
|
+
}
|
|
34967
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
34968
|
+
}
|
|
34969
|
+
return function refresh() {
|
|
34970
|
+
if (inFlight)
|
|
34971
|
+
return inFlight;
|
|
34972
|
+
const p = exchangeWithRetry().finally(() => {
|
|
34973
|
+
if (inFlight === p)
|
|
34974
|
+
inFlight = null;
|
|
34975
|
+
});
|
|
34976
|
+
inFlight = p;
|
|
34977
|
+
return p;
|
|
34978
|
+
};
|
|
34979
|
+
}
|
|
34980
|
+
|
|
34794
34981
|
// node_modules/@fetchproxy/protocol/dist/frames.js
|
|
34795
34982
|
var PROTOCOL_VERSION = 2;
|
|
34796
34983
|
var HKDF_SESSION_INFO = "fetchproxy/1.0.0/session";
|
|
@@ -38192,7 +38379,7 @@ function loadAccount(env = process.env) {
|
|
|
38192
38379
|
// package.json
|
|
38193
38380
|
var package_default = {
|
|
38194
38381
|
name: "canvas-parent-mcp",
|
|
38195
|
-
version: "1.1.
|
|
38382
|
+
version: "1.1.8",
|
|
38196
38383
|
mcpName: "io.github.chrischall/canvas-parent-mcp",
|
|
38197
38384
|
description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
|
|
38198
38385
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -38236,7 +38423,7 @@ var package_default = {
|
|
|
38236
38423
|
"test:watch": "vitest"
|
|
38237
38424
|
},
|
|
38238
38425
|
dependencies: {
|
|
38239
|
-
"@chrischall/mcp-utils": "^0.
|
|
38426
|
+
"@chrischall/mcp-utils": "^0.12.0",
|
|
38240
38427
|
"@fetchproxy/bootstrap": "^1.0.0",
|
|
38241
38428
|
"@fetchproxy/server": "^1.0.0",
|
|
38242
38429
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -38338,18 +38525,30 @@ var CookieSessionManager = class {
|
|
|
38338
38525
|
inFlight;
|
|
38339
38526
|
/** A cached *permanent* config error: once set, every `ensure` rethrows it. */
|
|
38340
38527
|
permanentError;
|
|
38528
|
+
/** When the current session was minted (login) or installed (seed) — for maxAgeMs. */
|
|
38529
|
+
sessionAt = 0;
|
|
38341
38530
|
loginFn;
|
|
38342
38531
|
isExpiredFn;
|
|
38343
38532
|
isPermanentErrorFn;
|
|
38533
|
+
maxAgeMs;
|
|
38534
|
+
now;
|
|
38535
|
+
onReplayLoginErrorFn;
|
|
38344
38536
|
constructor(opts) {
|
|
38345
38537
|
this.loginFn = opts.login;
|
|
38346
38538
|
this.isExpiredFn = opts.isExpired ?? (() => false);
|
|
38347
38539
|
this.isPermanentErrorFn = opts.isPermanentError ?? (() => false);
|
|
38540
|
+
this.maxAgeMs = opts.maxAgeMs;
|
|
38541
|
+
this.now = opts.now ?? Date.now;
|
|
38542
|
+
this.onReplayLoginErrorFn = opts.onReplayLoginError;
|
|
38348
38543
|
}
|
|
38349
38544
|
/** The current session, or `undefined` before the first successful login. */
|
|
38350
38545
|
get current() {
|
|
38351
38546
|
return this.session;
|
|
38352
38547
|
}
|
|
38548
|
+
/** True when {@link CookieSessionManagerOptions.maxAgeMs} says the session is too old. */
|
|
38549
|
+
isStale() {
|
|
38550
|
+
return this.maxAgeMs !== void 0 && this.now() - this.sessionAt >= this.maxAgeMs;
|
|
38551
|
+
}
|
|
38353
38552
|
/**
|
|
38354
38553
|
* Return the current session, or single-flight a login if there is none.
|
|
38355
38554
|
*
|
|
@@ -38358,10 +38557,16 @@ var CookieSessionManager = class {
|
|
|
38358
38557
|
* the next call retries. A login error classified permanent by
|
|
38359
38558
|
* {@link CookieSessionManagerOptions.isPermanentError} is cached and rethrown
|
|
38360
38559
|
* on every later call; a transient error is not cached (next call retries).
|
|
38560
|
+
* With {@link CookieSessionManagerOptions.maxAgeMs} set, a session past its
|
|
38561
|
+
* TTL is invalidated first, so the call falls through to a (single-flight)
|
|
38562
|
+
* re-login.
|
|
38361
38563
|
*/
|
|
38362
38564
|
async ensure() {
|
|
38363
|
-
if (this.session !== void 0)
|
|
38364
|
-
|
|
38565
|
+
if (this.session !== void 0) {
|
|
38566
|
+
if (!this.isStale())
|
|
38567
|
+
return this.session;
|
|
38568
|
+
this.invalidate();
|
|
38569
|
+
}
|
|
38365
38570
|
if (this.permanentError !== void 0)
|
|
38366
38571
|
throw this.permanentError;
|
|
38367
38572
|
if (this.inFlight === void 0) {
|
|
@@ -38369,6 +38574,21 @@ var CookieSessionManager = class {
|
|
|
38369
38574
|
}
|
|
38370
38575
|
return this.inFlight;
|
|
38371
38576
|
}
|
|
38577
|
+
/**
|
|
38578
|
+
* Install an externally-minted session (e.g. infinitecampus's CUPS linked-
|
|
38579
|
+
* district discovery, which mints sessions outside {@link
|
|
38580
|
+
* CookieSessionManagerOptions.login}). The seed becomes the current session
|
|
38581
|
+
* with a fresh {@link CookieSessionManagerOptions.maxAgeMs} clock, and any
|
|
38582
|
+
* in-flight login is DETACHED: its waiters still receive its result, but a
|
|
38583
|
+
* late resolution will not overwrite the seed. A cached permanent config
|
|
38584
|
+
* error is left intact — the login path is still misconfigured, and the next
|
|
38585
|
+
* post-seed re-login should keep saying so.
|
|
38586
|
+
*/
|
|
38587
|
+
seed(session) {
|
|
38588
|
+
this.session = session;
|
|
38589
|
+
this.sessionAt = this.now();
|
|
38590
|
+
this.inFlight = void 0;
|
|
38591
|
+
}
|
|
38372
38592
|
/**
|
|
38373
38593
|
* One login attempt. Self-clears `inFlight` on settle so a rejected login
|
|
38374
38594
|
* never sticks, and stamps the resulting session only while it's still the
|
|
@@ -38380,8 +38600,10 @@ var CookieSessionManager = class {
|
|
|
38380
38600
|
holder.p = (async () => {
|
|
38381
38601
|
try {
|
|
38382
38602
|
const session = await this.loginFn();
|
|
38383
|
-
if (this.inFlight === holder.p)
|
|
38603
|
+
if (this.inFlight === holder.p) {
|
|
38384
38604
|
this.session = session;
|
|
38605
|
+
this.sessionAt = this.now();
|
|
38606
|
+
}
|
|
38385
38607
|
return session;
|
|
38386
38608
|
} catch (err) {
|
|
38387
38609
|
if (this.isPermanentErrorFn(err))
|
|
@@ -38428,7 +38650,8 @@ var CookieSessionManager = class {
|
|
|
38428
38650
|
let fresh;
|
|
38429
38651
|
try {
|
|
38430
38652
|
fresh = await this.ensure();
|
|
38431
|
-
} catch {
|
|
38653
|
+
} catch (err) {
|
|
38654
|
+
this.onReplayLoginErrorFn?.(err);
|
|
38432
38655
|
return res;
|
|
38433
38656
|
}
|
|
38434
38657
|
return call(fresh);
|
|
@@ -38453,27 +38676,12 @@ function extractAuthenticityToken(html) {
|
|
|
38453
38676
|
}
|
|
38454
38677
|
return null;
|
|
38455
38678
|
}
|
|
38456
|
-
|
|
38457
|
-
|
|
38458
|
-
|
|
38459
|
-
|
|
38460
|
-
|
|
38461
|
-
|
|
38462
|
-
const value = nameValue.slice(eq + 1).trim();
|
|
38463
|
-
if (!name) return null;
|
|
38464
|
-
return { name, value };
|
|
38465
|
-
}
|
|
38466
|
-
function serializeCookies(cookies) {
|
|
38467
|
-
return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
38468
|
-
}
|
|
38469
|
-
function collectSetCookies(headers) {
|
|
38470
|
-
const out = [];
|
|
38471
|
-
for (const sc of headers.getSetCookie()) {
|
|
38472
|
-
const parsed = parseSetCookie(sc);
|
|
38473
|
-
if (parsed) out.push(parsed);
|
|
38474
|
-
}
|
|
38475
|
-
return out;
|
|
38476
|
-
}
|
|
38679
|
+
var SESSION_COOKIE_NAMES = [
|
|
38680
|
+
"canvas_session",
|
|
38681
|
+
"pseudonym_credentials",
|
|
38682
|
+
"_csrf_token",
|
|
38683
|
+
"log_session_id"
|
|
38684
|
+
];
|
|
38477
38685
|
async function sessionLogin(opts) {
|
|
38478
38686
|
const baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
38479
38687
|
const loginUrl = `${baseUrl}/login/canvas`;
|
|
@@ -38489,7 +38697,8 @@ async function sessionLogin(opts) {
|
|
|
38489
38697
|
);
|
|
38490
38698
|
}
|
|
38491
38699
|
const html = await getRes.text();
|
|
38492
|
-
const
|
|
38700
|
+
const jar = new CookieJar();
|
|
38701
|
+
jar.absorb(getRes.headers);
|
|
38493
38702
|
const authToken = extractAuthenticityToken(html);
|
|
38494
38703
|
if (!authToken) {
|
|
38495
38704
|
throw new SessionLoginError(
|
|
@@ -38511,14 +38720,14 @@ async function sessionLogin(opts) {
|
|
|
38511
38720
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
38512
38721
|
Accept: "text/html,application/xhtml+xml",
|
|
38513
38722
|
"User-Agent": USER_AGENT,
|
|
38514
|
-
Cookie:
|
|
38723
|
+
Cookie: jar.header(),
|
|
38515
38724
|
Origin: baseUrl,
|
|
38516
38725
|
Referer: loginUrl
|
|
38517
38726
|
},
|
|
38518
38727
|
body: body.toString()
|
|
38519
38728
|
});
|
|
38520
|
-
const
|
|
38521
|
-
const hasCreds =
|
|
38729
|
+
const respJar = parseCookieJar(postRes.headers.getSetCookie());
|
|
38730
|
+
const hasCreds = "pseudonym_credentials" in respJar.cookies;
|
|
38522
38731
|
if (!hasCreds) {
|
|
38523
38732
|
const location = postRes.headers.get("location") ?? "";
|
|
38524
38733
|
if (location && !location.startsWith(baseUrl) && !location.includes("sso.canvaslms.com")) {
|
|
@@ -38530,10 +38739,8 @@ async function sessionLogin(opts) {
|
|
|
38530
38739
|
"Login response did not include a pseudonym_credentials cookie \u2014 incorrect username or password, or the account is locked."
|
|
38531
38740
|
);
|
|
38532
38741
|
}
|
|
38533
|
-
const
|
|
38534
|
-
|
|
38535
|
-
);
|
|
38536
|
-
return { cookie: serializeCookies(sessionCookies) };
|
|
38742
|
+
const cookie = Object.entries(respJar.cookies).filter(([name]) => SESSION_COOKIE_NAMES.includes(name)).map(([name, value]) => `${name}=${value}`).join("; ");
|
|
38743
|
+
return { cookie };
|
|
38537
38744
|
}
|
|
38538
38745
|
|
|
38539
38746
|
// src/client.ts
|
|
@@ -38544,6 +38751,8 @@ var CanvasClient = class {
|
|
|
38544
38751
|
sessionLoginFn;
|
|
38545
38752
|
preloadedCookie;
|
|
38546
38753
|
auth;
|
|
38754
|
+
/** Lazily-built shared refresh_token exchanger (oauth mode only). */
|
|
38755
|
+
oauthRefresh = null;
|
|
38547
38756
|
/**
|
|
38548
38757
|
* `preloaded` is the fetchproxy escape hatch: when set, the client uses
|
|
38549
38758
|
* the supplied cookie header as-if it had just successfully run
|
|
@@ -38694,29 +38903,32 @@ var CanvasClient = class {
|
|
|
38694
38903
|
});
|
|
38695
38904
|
return result.cookie;
|
|
38696
38905
|
}
|
|
38906
|
+
/**
|
|
38907
|
+
* Exchange the refresh token for a fresh access token via the fleet-shared
|
|
38908
|
+
* `createOAuth2Refresher` (which owns the form-encoded POST, the single-
|
|
38909
|
+
* in-flight guard, and the redact-then-truncate sanitizing of upstream error
|
|
38910
|
+
* bodies — so if Canvas's /login/oauth2/token error echoes the client_secret
|
|
38911
|
+
* or refresh_token, it never reaches the client-facing error). Canvas-specific
|
|
38912
|
+
* bits stay here: the TokenExpiredError('oauth') wrapper and the 60s
|
|
38913
|
+
* proactive-expiry skew baked into `accessTokenExpiresAt`.
|
|
38914
|
+
*/
|
|
38697
38915
|
async refreshAccessToken(acct) {
|
|
38698
|
-
|
|
38699
|
-
|
|
38700
|
-
|
|
38701
|
-
client_secret: acct.clientSecret
|
|
38702
|
-
refresh_token: acct.refreshToken
|
|
38703
|
-
}).toString();
|
|
38704
|
-
const res = await fetch(`${acct.baseUrl}/login/oauth2/token`, {
|
|
38705
|
-
method: "POST",
|
|
38706
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
38707
|
-
body
|
|
38916
|
+
this.oauthRefresh ??= createOAuth2Refresher({
|
|
38917
|
+
endpoint: `${acct.baseUrl}/login/oauth2/token`,
|
|
38918
|
+
refreshToken: acct.refreshToken,
|
|
38919
|
+
params: { client_id: acct.clientId, client_secret: acct.clientSecret }
|
|
38708
38920
|
});
|
|
38709
|
-
|
|
38710
|
-
|
|
38711
|
-
|
|
38712
|
-
|
|
38713
|
-
|
|
38714
|
-
|
|
38921
|
+
let accessToken;
|
|
38922
|
+
let expiresIn;
|
|
38923
|
+
try {
|
|
38924
|
+
const result = await this.oauthRefresh();
|
|
38925
|
+
accessToken = result.accessToken;
|
|
38926
|
+
expiresIn = result.expiresIn ?? 3600;
|
|
38927
|
+
} catch (e) {
|
|
38928
|
+
throw new TokenExpiredError("oauth", e.message);
|
|
38715
38929
|
}
|
|
38716
|
-
const data = await res.json();
|
|
38717
|
-
const expiresIn = data.expires_in ?? 3600;
|
|
38718
38930
|
return {
|
|
38719
|
-
headers: { Authorization: `Bearer ${
|
|
38931
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
38720
38932
|
accessTokenExpiresAt: Date.now() + (expiresIn - 60) * 1e3
|
|
38721
38933
|
};
|
|
38722
38934
|
}
|
|
@@ -38778,16 +38990,7 @@ var FileExistsError = class extends Error {
|
|
|
38778
38990
|
// src/tools/_shared.ts
|
|
38779
38991
|
var textContent = textResult;
|
|
38780
38992
|
function buildPath(base, params = {}) {
|
|
38781
|
-
|
|
38782
|
-
for (const [key, value] of Object.entries(params)) {
|
|
38783
|
-
if (value === void 0 || value === null) continue;
|
|
38784
|
-
if (Array.isArray(value)) {
|
|
38785
|
-
for (const v of value) parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
|
|
38786
|
-
} else {
|
|
38787
|
-
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
38788
|
-
}
|
|
38789
|
-
}
|
|
38790
|
-
return parts.length ? `${base}?${parts.join("&")}` : base;
|
|
38993
|
+
return `${base}${buildQueryString(params)}`;
|
|
38791
38994
|
}
|
|
38792
38995
|
function userSegment(observeeId) {
|
|
38793
38996
|
return observeeId ? `users/${encodeURIComponent(observeeId)}` : "users/self";
|
|
@@ -39152,7 +39355,7 @@ try {
|
|
|
39152
39355
|
}
|
|
39153
39356
|
await runMcp({
|
|
39154
39357
|
name: "canvas",
|
|
39155
|
-
version: "1.1.
|
|
39358
|
+
version: "1.1.8",
|
|
39156
39359
|
// x-release-please-version
|
|
39157
39360
|
tools: [
|
|
39158
39361
|
(server) => {
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeFile, stat } from 'fs/promises';
|
|
2
2
|
import { dirname } from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { createOAuth2Refresher, parseLinkHeader } from '@chrischall/mcp-utils';
|
|
4
4
|
import { CookieSessionManager } from '@chrischall/mcp-utils/session';
|
|
5
5
|
import { sessionLogin as defaultSessionLogin } from './session-login.js';
|
|
6
6
|
// Re-export the fleet-shared RFC 5988 Link parser so existing importers
|
|
@@ -15,6 +15,8 @@ export class CanvasClient {
|
|
|
15
15
|
sessionLoginFn;
|
|
16
16
|
preloadedCookie;
|
|
17
17
|
auth;
|
|
18
|
+
/** Lazily-built shared refresh_token exchanger (oauth mode only). */
|
|
19
|
+
oauthRefresh = null;
|
|
18
20
|
/**
|
|
19
21
|
* `preloaded` is the fetchproxy escape hatch: when set, the client uses
|
|
20
22
|
* the supplied cookie header as-if it had just successfully run
|
|
@@ -187,30 +189,35 @@ export class CanvasClient {
|
|
|
187
189
|
});
|
|
188
190
|
return result.cookie;
|
|
189
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Exchange the refresh token for a fresh access token via the fleet-shared
|
|
194
|
+
* `createOAuth2Refresher` (which owns the form-encoded POST, the single-
|
|
195
|
+
* in-flight guard, and the redact-then-truncate sanitizing of upstream error
|
|
196
|
+
* bodies — so if Canvas's /login/oauth2/token error echoes the client_secret
|
|
197
|
+
* or refresh_token, it never reaches the client-facing error). Canvas-specific
|
|
198
|
+
* bits stay here: the TokenExpiredError('oauth') wrapper and the 60s
|
|
199
|
+
* proactive-expiry skew baked into `accessTokenExpiresAt`.
|
|
200
|
+
*/
|
|
190
201
|
async refreshAccessToken(acct) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
client_secret: acct.clientSecret,
|
|
195
|
-
refresh_token: acct.refreshToken,
|
|
196
|
-
}).toString();
|
|
197
|
-
const res = await fetch(`${acct.baseUrl}/login/oauth2/token`, {
|
|
198
|
-
method: 'POST',
|
|
199
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
200
|
-
body,
|
|
202
|
+
this.oauthRefresh ??= createOAuth2Refresher({
|
|
203
|
+
endpoint: `${acct.baseUrl}/login/oauth2/token`,
|
|
204
|
+
refreshToken: acct.refreshToken,
|
|
205
|
+
params: { client_id: acct.clientId, client_secret: acct.clientSecret },
|
|
201
206
|
});
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
let accessToken;
|
|
208
|
+
let expiresIn;
|
|
209
|
+
try {
|
|
210
|
+
const result = await this.oauthRefresh();
|
|
211
|
+
accessToken = result.accessToken;
|
|
212
|
+
expiresIn = result.expiresIn ?? 3600;
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
// The refresher always throws Error (McpToolError) with a pre-redacted,
|
|
216
|
+
// pre-truncated message — safe to embed as the TokenExpiredError detail.
|
|
217
|
+
throw new TokenExpiredError('oauth', e.message);
|
|
209
218
|
}
|
|
210
|
-
const data = await res.json();
|
|
211
|
-
const expiresIn = data.expires_in ?? 3600;
|
|
212
219
|
return {
|
|
213
|
-
headers: { Authorization: `Bearer ${
|
|
220
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
214
221
|
accessTokenExpiresAt: Date.now() + (expiresIn - 60) * 1000,
|
|
215
222
|
};
|
|
216
223
|
}
|
package/dist/index.js
CHANGED
package/dist/session-login.js
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
// place of an OAuth bearer token; on a 401 the manager re-invokes sessionLogin
|
|
11
11
|
// to mint a fresh jar. pseudonym_credentials is the "remember me" cookie with a
|
|
12
12
|
// meaningful expiry (~14 days); canvas_session piggybacks on it.
|
|
13
|
+
//
|
|
14
|
+
// Cookie parsing/serialization is the fleet-shared `CookieJar` /
|
|
15
|
+
// `parseCookieJar` from `@chrischall/mcp-utils` (same attribute-stripping
|
|
16
|
+
// `name=value; …` semantics the hand-rolled helpers here used to implement,
|
|
17
|
+
// plus deletion-marker handling).
|
|
18
|
+
import { CookieJar, parseCookieJar } from '@chrischall/mcp-utils';
|
|
13
19
|
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
|
|
14
20
|
'(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36';
|
|
15
21
|
export class SessionLoginError extends Error {
|
|
@@ -30,31 +36,13 @@ export function extractAuthenticityToken(html) {
|
|
|
30
36
|
}
|
|
31
37
|
return null;
|
|
32
38
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const value = nameValue.slice(eq + 1).trim();
|
|
41
|
-
if (!name)
|
|
42
|
-
return null;
|
|
43
|
-
return { name, value };
|
|
44
|
-
}
|
|
45
|
-
export function serializeCookies(cookies) {
|
|
46
|
-
return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
47
|
-
}
|
|
48
|
-
function collectSetCookies(headers) {
|
|
49
|
-
const out = [];
|
|
50
|
-
// Node 18+: Headers.getSetCookie() returns each Set-Cookie as a separate string.
|
|
51
|
-
for (const sc of headers.getSetCookie()) {
|
|
52
|
-
const parsed = parseSetCookie(sc);
|
|
53
|
-
if (parsed)
|
|
54
|
-
out.push(parsed);
|
|
55
|
-
}
|
|
56
|
-
return out;
|
|
57
|
-
}
|
|
39
|
+
/** The cookies that actually authenticate Canvas API calls. */
|
|
40
|
+
const SESSION_COOKIE_NAMES = [
|
|
41
|
+
'canvas_session',
|
|
42
|
+
'pseudonym_credentials',
|
|
43
|
+
'_csrf_token',
|
|
44
|
+
'log_session_id',
|
|
45
|
+
];
|
|
58
46
|
export async function sessionLogin(opts) {
|
|
59
47
|
const baseUrl = opts.baseUrl.replace(/\/$/, '');
|
|
60
48
|
const loginUrl = `${baseUrl}/login/canvas`;
|
|
@@ -69,7 +57,8 @@ export async function sessionLogin(opts) {
|
|
|
69
57
|
throw new SessionLoginError(`Login page fetch failed: ${getRes.status} ${getRes.statusText}`);
|
|
70
58
|
}
|
|
71
59
|
const html = await getRes.text();
|
|
72
|
-
const
|
|
60
|
+
const jar = new CookieJar();
|
|
61
|
+
jar.absorb(getRes.headers);
|
|
73
62
|
const authToken = extractAuthenticityToken(html);
|
|
74
63
|
if (!authToken) {
|
|
75
64
|
throw new SessionLoginError("Could not find authenticity_token on the login page. " +
|
|
@@ -93,7 +82,7 @@ export async function sessionLogin(opts) {
|
|
|
93
82
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
94
83
|
Accept: 'text/html,application/xhtml+xml',
|
|
95
84
|
'User-Agent': USER_AGENT,
|
|
96
|
-
Cookie:
|
|
85
|
+
Cookie: jar.header(),
|
|
97
86
|
Origin: baseUrl,
|
|
98
87
|
Referer: loginUrl,
|
|
99
88
|
},
|
|
@@ -102,8 +91,8 @@ export async function sessionLogin(opts) {
|
|
|
102
91
|
// 3. Verify success by looking for the pseudonym_credentials cookie. Canvas sets it
|
|
103
92
|
// only when authentication actually succeeded; a failed login returns a fresh
|
|
104
93
|
// csrf token but no credentials cookie.
|
|
105
|
-
const
|
|
106
|
-
const hasCreds =
|
|
94
|
+
const respJar = parseCookieJar(postRes.headers.getSetCookie());
|
|
95
|
+
const hasCreds = 'pseudonym_credentials' in respJar.cookies;
|
|
107
96
|
if (!hasCreds) {
|
|
108
97
|
const location = postRes.headers.get('location') ?? '';
|
|
109
98
|
if (location && !location.startsWith(baseUrl) && !location.includes('sso.canvaslms.com')) {
|
|
@@ -114,7 +103,10 @@ export async function sessionLogin(opts) {
|
|
|
114
103
|
'incorrect username or password, or the account is locked.');
|
|
115
104
|
}
|
|
116
105
|
// Filter to the cookies that actually authenticate API calls. Path/Secure/HttpOnly
|
|
117
|
-
// attributes are stripped
|
|
118
|
-
const
|
|
119
|
-
|
|
106
|
+
// attributes are stripped by parseCookieJar, so we just join name=value pairs.
|
|
107
|
+
const cookie = Object.entries(respJar.cookies)
|
|
108
|
+
.filter(([name]) => SESSION_COOKIE_NAMES.includes(name))
|
|
109
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
110
|
+
.join('; ');
|
|
111
|
+
return { cookie };
|
|
120
112
|
}
|
package/dist/tools/_shared.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { textResult } from '@chrischall/mcp-utils';
|
|
1
|
+
import { buildQueryString, textResult } from '@chrischall/mcp-utils';
|
|
2
2
|
/**
|
|
3
3
|
* Wrap a value as an MCP text content block — the standard tool return shape.
|
|
4
4
|
* Backed by the fleet-shared `textResult` from `@chrischall/mcp-utils` (same
|
|
@@ -19,19 +19,7 @@ export function toArray(value) {
|
|
|
19
19
|
return Array.isArray(value) ? value : [value];
|
|
20
20
|
}
|
|
21
21
|
export function buildPath(base, params = {}) {
|
|
22
|
-
|
|
23
|
-
for (const [key, value] of Object.entries(params)) {
|
|
24
|
-
if (value === undefined || value === null)
|
|
25
|
-
continue;
|
|
26
|
-
if (Array.isArray(value)) {
|
|
27
|
-
for (const v of value)
|
|
28
|
-
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return parts.length ? `${base}?${parts.join('&')}` : base;
|
|
22
|
+
return `${base}${buildQueryString(params)}`;
|
|
35
23
|
}
|
|
36
24
|
/** Compute the user path segment: 'users/self' or 'users/{id}'. */
|
|
37
25
|
export function userSegment(observeeId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvas-parent-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
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>",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"test:watch": "vitest"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@chrischall/mcp-utils": "^0.
|
|
47
|
+
"@chrischall/mcp-utils": "^0.12.0",
|
|
48
48
|
"@fetchproxy/bootstrap": "^1.0.0",
|
|
49
49
|
"@fetchproxy/server": "^1.0.0",
|
|
50
50
|
"@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.1.
|
|
9
|
+
"version": "1.1.8",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "canvas-parent-mcp",
|
|
14
|
-
"version": "1.1.
|
|
14
|
+
"version": "1.1.8",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|