canvas-parent-mcp 1.1.6 → 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/auth.js +10 -7
- package/dist/bundle.js +464 -114
- 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 +3 -3
- 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/auth.js
CHANGED
|
@@ -14,17 +14,20 @@
|
|
|
14
14
|
// most reliable mode but most schools have disabled token creation
|
|
15
15
|
// for non-admins. Unchanged from pre-fetchproxy behavior.
|
|
16
16
|
//
|
|
17
|
-
// 2.
|
|
18
|
-
// CANVAS_CLIENT_ID + CANVAS_CLIENT_SECRET + CANVAS_REFRESH_TOKEN →
|
|
19
|
-
// `grant_type=refresh_token` against `/login/oauth2/token`, bootstrap
|
|
20
|
-
// via the bundled QR-login helper. Unchanged.
|
|
21
|
-
//
|
|
22
|
-
// 3. Username/password session-scrape (existing)
|
|
17
|
+
// 2. Username/password session-scrape (existing)
|
|
23
18
|
// CANVAS_USERNAME + CANVAS_PASSWORD → scrape `authenticity_token`
|
|
24
19
|
// from `/login/canvas`, POST creds, capture `canvas_session` +
|
|
25
20
|
// `pseudonym_credentials` cookies. Brittle (breaks on SSO/2FA and
|
|
26
21
|
// every Canvas login-page restyling) but works for direct Canvas
|
|
27
|
-
// accounts.
|
|
22
|
+
// accounts. Takes precedence over OAuth (path 3) — see the
|
|
23
|
+
// precedence order in `loadAccount()` (config.ts), which returns the
|
|
24
|
+
// username/password account before checking the OAuth triple.
|
|
25
|
+
// Unchanged from pre-fetchproxy behavior.
|
|
26
|
+
//
|
|
27
|
+
// 3. OAuth refresh token (existing)
|
|
28
|
+
// CANVAS_CLIENT_ID + CANVAS_CLIENT_SECRET + CANVAS_REFRESH_TOKEN →
|
|
29
|
+
// `grant_type=refresh_token` against `/login/oauth2/token`, bootstrap
|
|
30
|
+
// via the bundled QR-login helper. Unchanged.
|
|
28
31
|
//
|
|
29
32
|
// 4. fetchproxy fallback (new)
|
|
30
33
|
// When no env vars are set, lift the user's session out of their
|
package/dist/bundle.js
CHANGED
|
@@ -13,7 +13,11 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
13
13
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
14
14
|
});
|
|
15
15
|
var __commonJS = (cb, mod) => function __require2() {
|
|
16
|
-
|
|
16
|
+
try {
|
|
17
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
18
|
+
} catch (e) {
|
|
19
|
+
throw mod = 0, e;
|
|
20
|
+
}
|
|
17
21
|
};
|
|
18
22
|
var __export = (target, all) => {
|
|
19
23
|
for (var name in all)
|
|
@@ -34687,6 +34691,20 @@ async function runMcp(opts) {
|
|
|
34687
34691
|
|
|
34688
34692
|
// node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
34689
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
|
+
}
|
|
34690
34708
|
var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
34691
34709
|
var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
34692
34710
|
var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
|
|
@@ -34707,8 +34725,12 @@ var API_KEY_RE = new RegExp([
|
|
|
34707
34725
|
// webhook signing secret (Stripe-style)
|
|
34708
34726
|
].map((p) => `\\b${p}`).join("|"), "g");
|
|
34709
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");
|
|
34710
34732
|
function redactSecrets(text) {
|
|
34711
|
-
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]");
|
|
34712
34734
|
}
|
|
34713
34735
|
function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
|
|
34714
34736
|
const str = text === null || text === void 0 ? "" : String(text);
|
|
@@ -34756,6 +34778,23 @@ async function loadDotenvSafely(opts = {}) {
|
|
|
34756
34778
|
}
|
|
34757
34779
|
|
|
34758
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
|
+
}
|
|
34759
34798
|
function parseLinkHeader(header) {
|
|
34760
34799
|
const out = {};
|
|
34761
34800
|
if (!header)
|
|
@@ -34767,6 +34806,90 @@ function parseLinkHeader(header) {
|
|
|
34767
34806
|
}
|
|
34768
34807
|
return out;
|
|
34769
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
|
+
}
|
|
34770
34893
|
|
|
34771
34894
|
// node_modules/@chrischall/mcp-utils/dist/zod/index.js
|
|
34772
34895
|
var PositiveInt = external_exports.number().int().positive();
|
|
@@ -34787,6 +34910,74 @@ var pageSchema = {
|
|
|
34787
34910
|
page_size: external_exports.number().int().min(1).max(200).default(50).describe("Number of items per page (1-200).")
|
|
34788
34911
|
};
|
|
34789
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
|
+
|
|
34790
34981
|
// node_modules/@fetchproxy/protocol/dist/frames.js
|
|
34791
34982
|
var PROTOCOL_VERSION = 2;
|
|
34792
34983
|
var HKDF_SESSION_INFO = "fetchproxy/1.0.0/session";
|
|
@@ -35743,10 +35934,20 @@ async function openEncryptedFrame(sessionKey, frame) {
|
|
|
35743
35934
|
|
|
35744
35935
|
// node_modules/@fetchproxy/server/dist/election.js
|
|
35745
35936
|
import { createServer, Server as HttpServer } from "node:http";
|
|
35937
|
+
var DEFAULT_BIND_TIMEOUT_MS = 5e3;
|
|
35746
35938
|
async function electRole(opts) {
|
|
35747
35939
|
const server = createServer();
|
|
35940
|
+
const bindTimeoutMs = opts.bindTimeoutMs ?? DEFAULT_BIND_TIMEOUT_MS;
|
|
35748
35941
|
return new Promise((resolve, reject) => {
|
|
35942
|
+
let timer;
|
|
35943
|
+
const clearBindTimer = () => {
|
|
35944
|
+
if (timer) {
|
|
35945
|
+
clearTimeout(timer);
|
|
35946
|
+
timer = void 0;
|
|
35947
|
+
}
|
|
35948
|
+
};
|
|
35749
35949
|
const onError = (e) => {
|
|
35950
|
+
clearBindTimer();
|
|
35750
35951
|
server.removeListener("listening", onListening);
|
|
35751
35952
|
if (e.code === "EADDRINUSE") {
|
|
35752
35953
|
try {
|
|
@@ -35759,11 +35960,25 @@ async function electRole(opts) {
|
|
|
35759
35960
|
}
|
|
35760
35961
|
};
|
|
35761
35962
|
const onListening = () => {
|
|
35963
|
+
clearBindTimer();
|
|
35762
35964
|
server.removeListener("error", onError);
|
|
35763
35965
|
resolve({ role: "host", server });
|
|
35764
35966
|
};
|
|
35765
35967
|
server.once("error", onError);
|
|
35766
35968
|
server.once("listening", onListening);
|
|
35969
|
+
if (bindTimeoutMs > 0) {
|
|
35970
|
+
timer = setTimeout(() => {
|
|
35971
|
+
server.removeListener("error", onError);
|
|
35972
|
+
server.removeListener("listening", onListening);
|
|
35973
|
+
try {
|
|
35974
|
+
server.close();
|
|
35975
|
+
} catch {
|
|
35976
|
+
}
|
|
35977
|
+
reject(new Error(`fetchproxy: bind to ${opts.host}:${opts.port} timed out after ${bindTimeoutMs}ms (port may be in a bad state)`));
|
|
35978
|
+
}, bindTimeoutMs);
|
|
35979
|
+
if (typeof timer.unref === "function")
|
|
35980
|
+
timer.unref();
|
|
35981
|
+
}
|
|
35767
35982
|
server.listen(opts.port, opts.host);
|
|
35768
35983
|
});
|
|
35769
35984
|
}
|
|
@@ -35856,6 +36071,44 @@ var SessionState = class {
|
|
|
35856
36071
|
}
|
|
35857
36072
|
};
|
|
35858
36073
|
|
|
36074
|
+
// node_modules/@fetchproxy/server/dist/session-ready.js
|
|
36075
|
+
var SESSION_READY_TIMEOUT_MS = 3e4;
|
|
36076
|
+
var FetchproxySessionNotReadyError = class extends Error {
|
|
36077
|
+
reason;
|
|
36078
|
+
pairCode;
|
|
36079
|
+
mcpId;
|
|
36080
|
+
hint;
|
|
36081
|
+
constructor(info) {
|
|
36082
|
+
const pairing = info.pairCode !== null && info.pairCode !== "";
|
|
36083
|
+
const hint = pairing ? `Open the Transporter extension popup and approve pair code ${info.pairCode} for "${info.mcpId}", then retry.` : `The extension is connected but hasn't confirmed a session for "${info.mcpId}" \u2014 sign in to the target site in that browser (and approve the requested scope if it changed), then retry.`;
|
|
36084
|
+
super(`fetchproxy: ${pairing ? "pairing not yet approved" : "no confirmed browser session"} for "${info.mcpId}". ${hint}`);
|
|
36085
|
+
this.name = "FetchproxySessionNotReadyError";
|
|
36086
|
+
this.reason = pairing ? "pair-required" : "not-ready";
|
|
36087
|
+
this.pairCode = pairing ? info.pairCode : null;
|
|
36088
|
+
this.mcpId = info.mcpId;
|
|
36089
|
+
this.hint = hint;
|
|
36090
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
36091
|
+
}
|
|
36092
|
+
};
|
|
36093
|
+
async function awaitSessionReady(ready, opts) {
|
|
36094
|
+
const ms = opts.timeoutMs ?? SESSION_READY_TIMEOUT_MS;
|
|
36095
|
+
if (ms <= 0)
|
|
36096
|
+
return ready;
|
|
36097
|
+
let timer;
|
|
36098
|
+
const timeout = new Promise((_, reject) => {
|
|
36099
|
+
timer = setTimeout(() => {
|
|
36100
|
+
reject(new FetchproxySessionNotReadyError({ mcpId: opts.mcpId, pairCode: opts.pendingPairCode() }));
|
|
36101
|
+
}, ms);
|
|
36102
|
+
timer.unref?.();
|
|
36103
|
+
});
|
|
36104
|
+
try {
|
|
36105
|
+
return await Promise.race([ready, timeout]);
|
|
36106
|
+
} finally {
|
|
36107
|
+
if (timer)
|
|
36108
|
+
clearTimeout(timer);
|
|
36109
|
+
}
|
|
36110
|
+
}
|
|
36111
|
+
|
|
35859
36112
|
// node_modules/@fetchproxy/server/dist/host.js
|
|
35860
36113
|
var PUBLIC_ORIGIN_RE = /^https?:\/\/(?!(127\.0\.0\.1|localhost)(:|$))/i;
|
|
35861
36114
|
var enc2 = new TextEncoder();
|
|
@@ -35943,6 +36196,29 @@ async function startHost(opts) {
|
|
|
35943
36196
|
return;
|
|
35944
36197
|
}
|
|
35945
36198
|
if (frame.type === "hello" && frame.role === "server") {
|
|
36199
|
+
const peerEdPub = fromB64(frame.identityEd25519Pub);
|
|
36200
|
+
const peerSigMsg = concatBytes(enc2.encode(frame.mcpId), fromB64(frame.sessionNonce));
|
|
36201
|
+
const peerSig = fromB64(frame.sessionSig);
|
|
36202
|
+
let peerSigOk = false;
|
|
36203
|
+
try {
|
|
36204
|
+
peerSigOk = await ed25519Verify(peerEdPub, peerSigMsg, peerSig);
|
|
36205
|
+
} catch {
|
|
36206
|
+
peerSigOk = false;
|
|
36207
|
+
}
|
|
36208
|
+
if (!peerSigOk) {
|
|
36209
|
+
console.warn("[fetchproxy] peer hello signature invalid \u2014 refusing registration (possible squatter)");
|
|
36210
|
+
ws.close(1008, "peer hello signature invalid");
|
|
36211
|
+
return;
|
|
36212
|
+
}
|
|
36213
|
+
const existing = peers.get(frame.mcpId);
|
|
36214
|
+
if (existing && existing.ws !== ws) {
|
|
36215
|
+
const existingEdPub = existing.helloFrame.identityEd25519Pub;
|
|
36216
|
+
if (existingEdPub !== frame.identityEd25519Pub) {
|
|
36217
|
+
console.warn("[fetchproxy] peer mcpId already mapped to a different identity \u2014 refusing (mcpId squatting)");
|
|
36218
|
+
ws.close(1008, "mcpId already registered to another identity");
|
|
36219
|
+
return;
|
|
36220
|
+
}
|
|
36221
|
+
}
|
|
35946
36222
|
identified = "peer";
|
|
35947
36223
|
peerMcpId = frame.mcpId;
|
|
35948
36224
|
peers.set(frame.mcpId, { ws, helloFrame: frame });
|
|
@@ -36035,8 +36311,10 @@ async function startHost(opts) {
|
|
|
36035
36311
|
resetSessionPromise();
|
|
36036
36312
|
disconnectListeners.forEach((cb) => cb());
|
|
36037
36313
|
}
|
|
36038
|
-
if (identified === "peer" && peerMcpId)
|
|
36039
|
-
peers.
|
|
36314
|
+
if (identified === "peer" && peerMcpId) {
|
|
36315
|
+
if (peers.get(peerMcpId)?.ws === ws)
|
|
36316
|
+
peers.delete(peerMcpId);
|
|
36317
|
+
}
|
|
36040
36318
|
});
|
|
36041
36319
|
});
|
|
36042
36320
|
return {
|
|
@@ -36052,7 +36330,10 @@ async function startHost(opts) {
|
|
|
36052
36330
|
});
|
|
36053
36331
|
}),
|
|
36054
36332
|
sendOwnInner: async (inner) => {
|
|
36055
|
-
const session = await ownSessionReady
|
|
36333
|
+
const session = await awaitSessionReady(ownSessionReady, {
|
|
36334
|
+
mcpId: opts.ownMcpId,
|
|
36335
|
+
pendingPairCode: () => ownPendingPairCode
|
|
36336
|
+
});
|
|
36056
36337
|
if (!extensionWs)
|
|
36057
36338
|
throw new Error("host: no extension connected");
|
|
36058
36339
|
const sealed = await sealInnerFrame(session.sessionKey, opts.ownMcpId, session.nextOutboundSeq(), inner);
|
|
@@ -36157,7 +36438,10 @@ async function startPeer(opts) {
|
|
|
36157
36438
|
ws,
|
|
36158
36439
|
session: sessionPromise,
|
|
36159
36440
|
sendInner: async (inner) => {
|
|
36160
|
-
await sessionPromise
|
|
36441
|
+
await awaitSessionReady(sessionPromise, {
|
|
36442
|
+
mcpId: opts.mcpId,
|
|
36443
|
+
pendingPairCode: () => pendingPairCode
|
|
36444
|
+
});
|
|
36161
36445
|
const s = session;
|
|
36162
36446
|
const sealed = await sealInnerFrame(s.sessionKey, opts.mcpId, s.nextOutboundSeq(), inner);
|
|
36163
36447
|
ws.send(JSON.stringify(sealed));
|
|
@@ -36823,6 +37107,35 @@ var FetchproxyServer = class {
|
|
|
36823
37107
|
this.keepAliveTimer = null;
|
|
36824
37108
|
}
|
|
36825
37109
|
}
|
|
37110
|
+
/**
|
|
37111
|
+
* Send an inner request frame via whichever bridge handle is active. If the
|
|
37112
|
+
* send throws (e.g. `FetchproxySessionNotReadyError` — the session never
|
|
37113
|
+
* confirmed), the frame never reached the bridge, so no reply will arrive:
|
|
37114
|
+
* drop the just-registered pending resolver for this id (it lives in exactly
|
|
37115
|
+
* one of the op maps — request ids are unique) so it doesn't leak until the
|
|
37116
|
+
* server closes, then rethrow.
|
|
37117
|
+
*/
|
|
37118
|
+
async sendInnerFrame(inner) {
|
|
37119
|
+
try {
|
|
37120
|
+
if (this.hostHandle) {
|
|
37121
|
+
await this.hostHandle.sendOwnInner(inner);
|
|
37122
|
+
} else if (this.peerHandle) {
|
|
37123
|
+
await this.peerHandle.sendInner(inner);
|
|
37124
|
+
}
|
|
37125
|
+
} catch (err) {
|
|
37126
|
+
if ("id" in inner && typeof inner.id === "number") {
|
|
37127
|
+
const { id } = inner;
|
|
37128
|
+
this.pending.delete(id);
|
|
37129
|
+
this.pendingReadCookies.delete(id);
|
|
37130
|
+
this.pendingStorage.delete(id);
|
|
37131
|
+
this.pendingCapture.delete(id);
|
|
37132
|
+
this.pendingRedirect.delete(id);
|
|
37133
|
+
this.pendingDownload.delete(id);
|
|
37134
|
+
this.pendingIdb.delete(id);
|
|
37135
|
+
}
|
|
37136
|
+
throw err;
|
|
37137
|
+
}
|
|
37138
|
+
}
|
|
36826
37139
|
/**
|
|
36827
37140
|
* Single bridge round-trip, wrapped by `fetchTimeoutMs` when set.
|
|
36828
37141
|
* On timeout returns the `{ok:false, kind:'timeout'}` envelope —
|
|
@@ -36834,11 +37147,7 @@ var FetchproxyServer = class {
|
|
|
36834
37147
|
const pending = new Promise((resolve) => {
|
|
36835
37148
|
this.pending.set(id, resolve);
|
|
36836
37149
|
});
|
|
36837
|
-
|
|
36838
|
-
await this.hostHandle.sendOwnInner(inner);
|
|
36839
|
-
} else if (this.peerHandle) {
|
|
36840
|
-
await this.peerHandle.sendInner(inner);
|
|
36841
|
-
}
|
|
37150
|
+
await this.sendInnerFrame(inner);
|
|
36842
37151
|
const timeoutMs = this.opts.fetchTimeoutMs;
|
|
36843
37152
|
if (timeoutMs === void 0 || timeoutMs <= 0)
|
|
36844
37153
|
return pending;
|
|
@@ -36867,6 +37176,51 @@ var FetchproxyServer = class {
|
|
|
36867
37176
|
clearTimeout(timer);
|
|
36868
37177
|
}
|
|
36869
37178
|
}
|
|
37179
|
+
/**
|
|
37180
|
+
* FP-B2: bound a non-`fetch` verb's reply wait by `fetchTimeoutMs`.
|
|
37181
|
+
*
|
|
37182
|
+
* Before this, only `fetch()` raced its `pending` reply against a timer
|
|
37183
|
+
* (`_fetchOnceWithTimeout`); `readCookies`, the storage reads,
|
|
37184
|
+
* `capture_request_header`, `capture_redirect`, `download`, and
|
|
37185
|
+
* `read_indexed_db` awaited their `pending` promise with no race, so a
|
|
37186
|
+
* wedged extension hung the tool call indefinitely.
|
|
37187
|
+
*
|
|
37188
|
+
* `pending` is the already-registered reply promise. `pendingMap`/`id`
|
|
37189
|
+
* point at the op-specific map entry so we can drop it on expiry exactly
|
|
37190
|
+
* as `_fetchOnceWithTimeout` does — otherwise a late bridge reply would
|
|
37191
|
+
* resolve into a stale resolver / leak. On timeout we reject with a
|
|
37192
|
+
* `FetchproxyTimeoutError` (the same throwable the convenience methods
|
|
37193
|
+
* already surface for fetch timeouts). `0`/unset opts out (unbounded),
|
|
37194
|
+
* matching the fetch path.
|
|
37195
|
+
*/
|
|
37196
|
+
async _withVerbTimeout(pending, pendingMap, id, url2) {
|
|
37197
|
+
const timeoutMs = this.opts.fetchTimeoutMs;
|
|
37198
|
+
if (timeoutMs === void 0 || timeoutMs <= 0)
|
|
37199
|
+
return pending;
|
|
37200
|
+
let timer;
|
|
37201
|
+
const start = Date.now();
|
|
37202
|
+
try {
|
|
37203
|
+
return await Promise.race([
|
|
37204
|
+
pending,
|
|
37205
|
+
new Promise((_resolve, reject) => {
|
|
37206
|
+
timer = setTimeout(() => {
|
|
37207
|
+
pendingMap.delete(id);
|
|
37208
|
+
reject(new FetchproxyTimeoutError({
|
|
37209
|
+
url: url2,
|
|
37210
|
+
timeoutMs,
|
|
37211
|
+
role: this.role,
|
|
37212
|
+
port: this.opts.port,
|
|
37213
|
+
elapsedMs: Date.now() - start,
|
|
37214
|
+
retryAttempted: false
|
|
37215
|
+
}));
|
|
37216
|
+
}, timeoutMs);
|
|
37217
|
+
})
|
|
37218
|
+
]);
|
|
37219
|
+
} finally {
|
|
37220
|
+
if (timer)
|
|
37221
|
+
clearTimeout(timer);
|
|
37222
|
+
}
|
|
37223
|
+
}
|
|
36870
37224
|
/**
|
|
36871
37225
|
* Map an `ok:false` fetch result to its typed throwable. Centralizes
|
|
36872
37226
|
* the kind-to-error-class switch so `request()` and (via the same
|
|
@@ -37157,12 +37511,8 @@ var FetchproxyServer = class {
|
|
|
37157
37511
|
const pending = new Promise((resolve) => {
|
|
37158
37512
|
this.pendingReadCookies.set(id, resolve);
|
|
37159
37513
|
});
|
|
37160
|
-
|
|
37161
|
-
|
|
37162
|
-
} else if (this.peerHandle) {
|
|
37163
|
-
await this.peerHandle.sendInner(inner);
|
|
37164
|
-
}
|
|
37165
|
-
const result = await pending;
|
|
37514
|
+
await this.sendInnerFrame(inner);
|
|
37515
|
+
const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
|
|
37166
37516
|
if (!result.ok) {
|
|
37167
37517
|
throw new FetchproxyProtocolError(result.error);
|
|
37168
37518
|
}
|
|
@@ -37228,12 +37578,8 @@ var FetchproxyServer = class {
|
|
|
37228
37578
|
const pending = new Promise((resolve, reject) => {
|
|
37229
37579
|
this.pendingStorage.set(id, { resolve, reject });
|
|
37230
37580
|
});
|
|
37231
|
-
|
|
37232
|
-
|
|
37233
|
-
} else if (this.peerHandle) {
|
|
37234
|
-
await this.peerHandle.sendInner(inner);
|
|
37235
|
-
}
|
|
37236
|
-
return pending;
|
|
37581
|
+
await this.sendInnerFrame(inner);
|
|
37582
|
+
return this._withVerbTimeout(pending, this.pendingStorage, id, `https://${host}`);
|
|
37237
37583
|
}
|
|
37238
37584
|
/**
|
|
37239
37585
|
* 0.3.0+: snapshot the next outgoing request's named header. Single-
|
|
@@ -37337,12 +37683,8 @@ var FetchproxyServer = class {
|
|
|
37337
37683
|
const pending = new Promise((resolve, reject) => {
|
|
37338
37684
|
this.pendingCapture.set(id, { resolve, reject });
|
|
37339
37685
|
});
|
|
37340
|
-
|
|
37341
|
-
|
|
37342
|
-
} else if (this.peerHandle) {
|
|
37343
|
-
await this.peerHandle.sendInner(inner);
|
|
37344
|
-
}
|
|
37345
|
-
return pending;
|
|
37686
|
+
await this.sendInnerFrame(inner);
|
|
37687
|
+
return this._withVerbTimeout(pending, this.pendingCapture, id, `https://${opts.host}${opts.path ?? "/*"}`);
|
|
37346
37688
|
}
|
|
37347
37689
|
/**
|
|
37348
37690
|
* Snapshot the redirect target URL of the next request the browser
|
|
@@ -37426,12 +37768,8 @@ var FetchproxyServer = class {
|
|
|
37426
37768
|
const pending = new Promise((resolve, reject) => {
|
|
37427
37769
|
this.pendingRedirect.set(id, { resolve, reject });
|
|
37428
37770
|
});
|
|
37429
|
-
|
|
37430
|
-
|
|
37431
|
-
} else if (this.peerHandle) {
|
|
37432
|
-
await this.peerHandle.sendInner(inner);
|
|
37433
|
-
}
|
|
37434
|
-
return pending;
|
|
37771
|
+
await this.sendInnerFrame(inner);
|
|
37772
|
+
return this._withVerbTimeout(pending, this.pendingRedirect, id, `https://${opts.host}${opts.path ?? "/*"}`);
|
|
37435
37773
|
}
|
|
37436
37774
|
/**
|
|
37437
37775
|
* Download `url` through the BROWSER's own network stack via
|
|
@@ -37512,12 +37850,8 @@ var FetchproxyServer = class {
|
|
|
37512
37850
|
const pending = new Promise((resolve, reject) => {
|
|
37513
37851
|
this.pendingDownload.set(id, { resolve, reject });
|
|
37514
37852
|
});
|
|
37515
|
-
|
|
37516
|
-
|
|
37517
|
-
} else if (this.peerHandle) {
|
|
37518
|
-
await this.peerHandle.sendInner(inner);
|
|
37519
|
-
}
|
|
37520
|
-
return pending;
|
|
37853
|
+
await this.sendInnerFrame(inner);
|
|
37854
|
+
return this._withVerbTimeout(pending, this.pendingDownload, id, opts.url);
|
|
37521
37855
|
}
|
|
37522
37856
|
/**
|
|
37523
37857
|
* 0.4.0+: read declared IndexedDB keys from the user's signed-in
|
|
@@ -37564,12 +37898,8 @@ var FetchproxyServer = class {
|
|
|
37564
37898
|
const pending = new Promise((resolve, reject) => {
|
|
37565
37899
|
this.pendingIdb.set(id, { resolve, reject });
|
|
37566
37900
|
});
|
|
37567
|
-
|
|
37568
|
-
|
|
37569
|
-
} else if (this.peerHandle) {
|
|
37570
|
-
await this.peerHandle.sendInner(inner);
|
|
37571
|
-
}
|
|
37572
|
-
return pending;
|
|
37901
|
+
await this.sendInnerFrame(inner);
|
|
37902
|
+
return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
|
|
37573
37903
|
}
|
|
37574
37904
|
assertScopeSubset(requested, declared, label) {
|
|
37575
37905
|
const undeclared = undeclaredKeys(requested, declared);
|
|
@@ -38049,7 +38379,7 @@ function loadAccount(env = process.env) {
|
|
|
38049
38379
|
// package.json
|
|
38050
38380
|
var package_default = {
|
|
38051
38381
|
name: "canvas-parent-mcp",
|
|
38052
|
-
version: "1.1.
|
|
38382
|
+
version: "1.1.8",
|
|
38053
38383
|
mcpName: "io.github.chrischall/canvas-parent-mcp",
|
|
38054
38384
|
description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
|
|
38055
38385
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -38093,7 +38423,7 @@ var package_default = {
|
|
|
38093
38423
|
"test:watch": "vitest"
|
|
38094
38424
|
},
|
|
38095
38425
|
dependencies: {
|
|
38096
|
-
"@chrischall/mcp-utils": "^0.
|
|
38426
|
+
"@chrischall/mcp-utils": "^0.12.0",
|
|
38097
38427
|
"@fetchproxy/bootstrap": "^1.0.0",
|
|
38098
38428
|
"@fetchproxy/server": "^1.0.0",
|
|
38099
38429
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -38101,7 +38431,7 @@ var package_default = {
|
|
|
38101
38431
|
zod: "^4.4.3"
|
|
38102
38432
|
},
|
|
38103
38433
|
devDependencies: {
|
|
38104
|
-
"@types/node": "^
|
|
38434
|
+
"@types/node": "^26.0.0",
|
|
38105
38435
|
"@vitest/coverage-v8": "^4.1.7",
|
|
38106
38436
|
esbuild: "^0.28.0",
|
|
38107
38437
|
typescript: "^6.0.3",
|
|
@@ -38195,18 +38525,30 @@ var CookieSessionManager = class {
|
|
|
38195
38525
|
inFlight;
|
|
38196
38526
|
/** A cached *permanent* config error: once set, every `ensure` rethrows it. */
|
|
38197
38527
|
permanentError;
|
|
38528
|
+
/** When the current session was minted (login) or installed (seed) — for maxAgeMs. */
|
|
38529
|
+
sessionAt = 0;
|
|
38198
38530
|
loginFn;
|
|
38199
38531
|
isExpiredFn;
|
|
38200
38532
|
isPermanentErrorFn;
|
|
38533
|
+
maxAgeMs;
|
|
38534
|
+
now;
|
|
38535
|
+
onReplayLoginErrorFn;
|
|
38201
38536
|
constructor(opts) {
|
|
38202
38537
|
this.loginFn = opts.login;
|
|
38203
|
-
this.isExpiredFn = opts.isExpired;
|
|
38538
|
+
this.isExpiredFn = opts.isExpired ?? (() => false);
|
|
38204
38539
|
this.isPermanentErrorFn = opts.isPermanentError ?? (() => false);
|
|
38540
|
+
this.maxAgeMs = opts.maxAgeMs;
|
|
38541
|
+
this.now = opts.now ?? Date.now;
|
|
38542
|
+
this.onReplayLoginErrorFn = opts.onReplayLoginError;
|
|
38205
38543
|
}
|
|
38206
38544
|
/** The current session, or `undefined` before the first successful login. */
|
|
38207
38545
|
get current() {
|
|
38208
38546
|
return this.session;
|
|
38209
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
|
+
}
|
|
38210
38552
|
/**
|
|
38211
38553
|
* Return the current session, or single-flight a login if there is none.
|
|
38212
38554
|
*
|
|
@@ -38215,10 +38557,16 @@ var CookieSessionManager = class {
|
|
|
38215
38557
|
* the next call retries. A login error classified permanent by
|
|
38216
38558
|
* {@link CookieSessionManagerOptions.isPermanentError} is cached and rethrown
|
|
38217
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.
|
|
38218
38563
|
*/
|
|
38219
38564
|
async ensure() {
|
|
38220
|
-
if (this.session !== void 0)
|
|
38221
|
-
|
|
38565
|
+
if (this.session !== void 0) {
|
|
38566
|
+
if (!this.isStale())
|
|
38567
|
+
return this.session;
|
|
38568
|
+
this.invalidate();
|
|
38569
|
+
}
|
|
38222
38570
|
if (this.permanentError !== void 0)
|
|
38223
38571
|
throw this.permanentError;
|
|
38224
38572
|
if (this.inFlight === void 0) {
|
|
@@ -38226,6 +38574,21 @@ var CookieSessionManager = class {
|
|
|
38226
38574
|
}
|
|
38227
38575
|
return this.inFlight;
|
|
38228
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
|
+
}
|
|
38229
38592
|
/**
|
|
38230
38593
|
* One login attempt. Self-clears `inFlight` on settle so a rejected login
|
|
38231
38594
|
* never sticks, and stamps the resulting session only while it's still the
|
|
@@ -38237,8 +38600,10 @@ var CookieSessionManager = class {
|
|
|
38237
38600
|
holder.p = (async () => {
|
|
38238
38601
|
try {
|
|
38239
38602
|
const session = await this.loginFn();
|
|
38240
|
-
if (this.inFlight === holder.p)
|
|
38603
|
+
if (this.inFlight === holder.p) {
|
|
38241
38604
|
this.session = session;
|
|
38605
|
+
this.sessionAt = this.now();
|
|
38606
|
+
}
|
|
38242
38607
|
return session;
|
|
38243
38608
|
} catch (err) {
|
|
38244
38609
|
if (this.isPermanentErrorFn(err))
|
|
@@ -38270,6 +38635,10 @@ var CookieSessionManager = class {
|
|
|
38270
38635
|
* rather than looping. If the re-login itself fails, the original
|
|
38271
38636
|
* (expired-looking) response is returned so the caller can surface a clean
|
|
38272
38637
|
* sign-in error rather than the login failure.
|
|
38638
|
+
*
|
|
38639
|
+
* `call` resolves to `R` (the generic response type, default {@link Response});
|
|
38640
|
+
* the manager passes it untouched to {@link CookieSessionManagerOptions.isExpired}
|
|
38641
|
+
* and returns it untouched, so a custom transport type flows through cleanly.
|
|
38273
38642
|
*/
|
|
38274
38643
|
async withSession(call) {
|
|
38275
38644
|
const session = await this.ensure();
|
|
@@ -38281,7 +38650,8 @@ var CookieSessionManager = class {
|
|
|
38281
38650
|
let fresh;
|
|
38282
38651
|
try {
|
|
38283
38652
|
fresh = await this.ensure();
|
|
38284
|
-
} catch {
|
|
38653
|
+
} catch (err) {
|
|
38654
|
+
this.onReplayLoginErrorFn?.(err);
|
|
38285
38655
|
return res;
|
|
38286
38656
|
}
|
|
38287
38657
|
return call(fresh);
|
|
@@ -38306,27 +38676,12 @@ function extractAuthenticityToken(html) {
|
|
|
38306
38676
|
}
|
|
38307
38677
|
return null;
|
|
38308
38678
|
}
|
|
38309
|
-
|
|
38310
|
-
|
|
38311
|
-
|
|
38312
|
-
|
|
38313
|
-
|
|
38314
|
-
|
|
38315
|
-
const value = nameValue.slice(eq + 1).trim();
|
|
38316
|
-
if (!name) return null;
|
|
38317
|
-
return { name, value };
|
|
38318
|
-
}
|
|
38319
|
-
function serializeCookies(cookies) {
|
|
38320
|
-
return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
38321
|
-
}
|
|
38322
|
-
function collectSetCookies(headers) {
|
|
38323
|
-
const out = [];
|
|
38324
|
-
for (const sc of headers.getSetCookie()) {
|
|
38325
|
-
const parsed = parseSetCookie(sc);
|
|
38326
|
-
if (parsed) out.push(parsed);
|
|
38327
|
-
}
|
|
38328
|
-
return out;
|
|
38329
|
-
}
|
|
38679
|
+
var SESSION_COOKIE_NAMES = [
|
|
38680
|
+
"canvas_session",
|
|
38681
|
+
"pseudonym_credentials",
|
|
38682
|
+
"_csrf_token",
|
|
38683
|
+
"log_session_id"
|
|
38684
|
+
];
|
|
38330
38685
|
async function sessionLogin(opts) {
|
|
38331
38686
|
const baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
38332
38687
|
const loginUrl = `${baseUrl}/login/canvas`;
|
|
@@ -38342,7 +38697,8 @@ async function sessionLogin(opts) {
|
|
|
38342
38697
|
);
|
|
38343
38698
|
}
|
|
38344
38699
|
const html = await getRes.text();
|
|
38345
|
-
const
|
|
38700
|
+
const jar = new CookieJar();
|
|
38701
|
+
jar.absorb(getRes.headers);
|
|
38346
38702
|
const authToken = extractAuthenticityToken(html);
|
|
38347
38703
|
if (!authToken) {
|
|
38348
38704
|
throw new SessionLoginError(
|
|
@@ -38364,14 +38720,14 @@ async function sessionLogin(opts) {
|
|
|
38364
38720
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
38365
38721
|
Accept: "text/html,application/xhtml+xml",
|
|
38366
38722
|
"User-Agent": USER_AGENT,
|
|
38367
|
-
Cookie:
|
|
38723
|
+
Cookie: jar.header(),
|
|
38368
38724
|
Origin: baseUrl,
|
|
38369
38725
|
Referer: loginUrl
|
|
38370
38726
|
},
|
|
38371
38727
|
body: body.toString()
|
|
38372
38728
|
});
|
|
38373
|
-
const
|
|
38374
|
-
const hasCreds =
|
|
38729
|
+
const respJar = parseCookieJar(postRes.headers.getSetCookie());
|
|
38730
|
+
const hasCreds = "pseudonym_credentials" in respJar.cookies;
|
|
38375
38731
|
if (!hasCreds) {
|
|
38376
38732
|
const location = postRes.headers.get("location") ?? "";
|
|
38377
38733
|
if (location && !location.startsWith(baseUrl) && !location.includes("sso.canvaslms.com")) {
|
|
@@ -38383,10 +38739,8 @@ async function sessionLogin(opts) {
|
|
|
38383
38739
|
"Login response did not include a pseudonym_credentials cookie \u2014 incorrect username or password, or the account is locked."
|
|
38384
38740
|
);
|
|
38385
38741
|
}
|
|
38386
|
-
const
|
|
38387
|
-
|
|
38388
|
-
);
|
|
38389
|
-
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 };
|
|
38390
38744
|
}
|
|
38391
38745
|
|
|
38392
38746
|
// src/client.ts
|
|
@@ -38397,6 +38751,8 @@ var CanvasClient = class {
|
|
|
38397
38751
|
sessionLoginFn;
|
|
38398
38752
|
preloadedCookie;
|
|
38399
38753
|
auth;
|
|
38754
|
+
/** Lazily-built shared refresh_token exchanger (oauth mode only). */
|
|
38755
|
+
oauthRefresh = null;
|
|
38400
38756
|
/**
|
|
38401
38757
|
* `preloaded` is the fetchproxy escape hatch: when set, the client uses
|
|
38402
38758
|
* the supplied cookie header as-if it had just successfully run
|
|
@@ -38547,29 +38903,32 @@ var CanvasClient = class {
|
|
|
38547
38903
|
});
|
|
38548
38904
|
return result.cookie;
|
|
38549
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
|
+
*/
|
|
38550
38915
|
async refreshAccessToken(acct) {
|
|
38551
|
-
|
|
38552
|
-
|
|
38553
|
-
|
|
38554
|
-
client_secret: acct.clientSecret
|
|
38555
|
-
refresh_token: acct.refreshToken
|
|
38556
|
-
}).toString();
|
|
38557
|
-
const res = await fetch(`${acct.baseUrl}/login/oauth2/token`, {
|
|
38558
|
-
method: "POST",
|
|
38559
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
38560
|
-
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 }
|
|
38561
38920
|
});
|
|
38562
|
-
|
|
38563
|
-
|
|
38564
|
-
|
|
38565
|
-
|
|
38566
|
-
|
|
38567
|
-
|
|
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);
|
|
38568
38929
|
}
|
|
38569
|
-
const data = await res.json();
|
|
38570
|
-
const expiresIn = data.expires_in ?? 3600;
|
|
38571
38930
|
return {
|
|
38572
|
-
headers: { Authorization: `Bearer ${
|
|
38931
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
38573
38932
|
accessTokenExpiresAt: Date.now() + (expiresIn - 60) * 1e3
|
|
38574
38933
|
};
|
|
38575
38934
|
}
|
|
@@ -38631,16 +38990,7 @@ var FileExistsError = class extends Error {
|
|
|
38631
38990
|
// src/tools/_shared.ts
|
|
38632
38991
|
var textContent = textResult;
|
|
38633
38992
|
function buildPath(base, params = {}) {
|
|
38634
|
-
|
|
38635
|
-
for (const [key, value] of Object.entries(params)) {
|
|
38636
|
-
if (value === void 0 || value === null) continue;
|
|
38637
|
-
if (Array.isArray(value)) {
|
|
38638
|
-
for (const v of value) parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`);
|
|
38639
|
-
} else {
|
|
38640
|
-
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
38641
|
-
}
|
|
38642
|
-
}
|
|
38643
|
-
return parts.length ? `${base}?${parts.join("&")}` : base;
|
|
38993
|
+
return `${base}${buildQueryString(params)}`;
|
|
38644
38994
|
}
|
|
38645
38995
|
function userSegment(observeeId) {
|
|
38646
38996
|
return observeeId ? `users/${encodeURIComponent(observeeId)}` : "users/self";
|
|
@@ -39005,7 +39355,7 @@ try {
|
|
|
39005
39355
|
}
|
|
39006
39356
|
await runMcp({
|
|
39007
39357
|
name: "canvas",
|
|
39008
|
-
version: "1.1.
|
|
39358
|
+
version: "1.1.8",
|
|
39009
39359
|
// x-release-please-version
|
|
39010
39360
|
tools: [
|
|
39011
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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"zod": "^4.4.3"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@types/node": "^
|
|
55
|
+
"@types/node": "^26.0.0",
|
|
56
56
|
"@vitest/coverage-v8": "^4.1.7",
|
|
57
57
|
"esbuild": "^0.28.0",
|
|
58
58
|
"typescript": "^6.0.3",
|
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
|
},
|