canvas-parent-mcp 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2.2"
10
+ "version": "1.2.4"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Canvas LMS",
16
16
  "source": "./",
17
17
  "description": "Canvas LMS (Instructure) MCP server for Claude — student/observer access via natural language",
18
- "version": "1.2.2",
18
+ "version": "1.2.4",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
3
  "displayName": "Canvas LMS",
4
- "version": "1.2.2",
4
+ "version": "1.2.4",
5
5
  "description": "Canvas LMS (Instructure) MCP server for Claude — courses, grades, assignments, announcements, planner, conversations, files",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/auth.js CHANGED
@@ -54,7 +54,7 @@
54
54
  // - `@fetchproxy/bootstrap` is mocked at the module boundary in tests.
55
55
  // - `loadAccount()` (the existing env-var resolver) is reused as-is so
56
56
  // the legacy paths keep working unchanged.
57
- import { bootstrap } from '@fetchproxy/bootstrap';
57
+ import { createSessionLifter } from '@fetchproxy/bootstrap';
58
58
  import { classifyBridgeError } from '@fetchproxy/server';
59
59
  import { readEnvVar } from '@chrischall/mcp-utils';
60
60
  import { loadAccount } from './config.js';
@@ -98,6 +98,12 @@ export async function resolveAuth() {
98
98
  }
99
99
  }
100
100
  // ── Path 4: fetchproxy fallback.
101
+ //
102
+ // NOTE: the browser is NOT read here. resolveAuth() runs once at process
103
+ // start; the lift is handed to the client as `refresh` and runs lazily on
104
+ // the first request and again on every 401. A cookie captured at boot made
105
+ // a 401 terminal — the account has empty creds, so there was nothing to
106
+ // re-mint with and only a restart recovered.
101
107
  if (!fetchproxyDisabled()) {
102
108
  // CANVAS_BASE_URL is guaranteed valid here — loadAccount() validates it
103
109
  // before throwing the NO_ENV_CONFIG_MARKER error we caught above.
@@ -113,60 +119,22 @@ export async function resolveAuth() {
113
119
  : baseHost;
114
120
  const name = readEnv('CANVAS_NAME') ?? baseHost;
115
121
  const cleanBaseUrl = baseUrl.replace(/\/$/, '');
116
- try {
117
- const session = await bootstrap({
118
- serverName: pkg.name,
119
- version: pkg.version,
120
- domains: [declaredDomain],
121
- declare: {
122
- cookies: ['canvas_session', 'pseudonym_credentials'],
123
- localStorage: [],
124
- sessionStorage: [],
125
- captureHeaders: [],
126
- },
127
- });
128
- const canvasSession = session.cookies['canvas_session'];
129
- const pseudoCreds = session.cookies['pseudonym_credentials'];
130
- if (!canvasSession || !pseudoCreds) {
131
- throw new Error(`required cookies not found on ${baseHost}. ` +
132
- 'Sign into your Canvas instance in the browser ' +
133
- '(with the fetchproxy extension installed) and retry.');
134
- }
135
- const cookie = `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
136
- // Synthesize a session account with empty creds — the client will see
137
- // `preloaded` and skip the form login. This mirrors the cookie shape
138
- // the legacy session-scrape path produces, so everything downstream
139
- // (Cookie header, 401-retry, file downloads) keeps working.
140
- const account = {
141
- mode: 'session',
142
- name,
143
- baseUrl: cleanBaseUrl,
144
- username: '',
145
- password: '',
146
- };
147
- return {
148
- account,
149
- preloaded: { cookie },
150
- source: 'fetchproxy',
151
- };
152
- }
153
- catch (e) {
154
- // 0.8.0+ typed-error discrimination. The fetchproxy server already
155
- // retries once on SW eviction (bridgeReviveDelayMs=2000 default), so
156
- // a thrown FetchproxyBridgeDownError means the retry also failed —
157
- // the extension's service worker is genuinely down and the user
158
- // needs to wake it. The `.hint` is the actionable copy
159
- // ("click the extension toolbar icon...") that we'd otherwise have
160
- // to hand-write here. Surface it verbatim so users in path 4 get
161
- // the same self-service guidance as path 5.
162
- if (classifyBridgeError(e) === 'bridge_down') {
163
- const downErr = e;
164
- throw new Error(`Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
165
- }
166
- const msg = e instanceof Error ? e.message : String(e);
167
- throw new Error(`Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, ` +
168
- `and fetchproxy fallback failed: ${msg}`);
169
- }
122
+ // Synthesized session account with empty creds — the client sees `refresh`
123
+ // and skips the form login. Cookie shape matches the legacy session-scrape
124
+ // path so everything downstream (Cookie header, 401 replay, downloads)
125
+ // keeps working.
126
+ const account = {
127
+ mode: 'session',
128
+ name,
129
+ baseUrl: cleanBaseUrl,
130
+ username: '',
131
+ password: '',
132
+ };
133
+ return {
134
+ account,
135
+ refresh: () => liftBrowserCookie(declaredDomain, baseHost),
136
+ source: 'fetchproxy',
137
+ };
170
138
  }
171
139
  // ── Path 5: nothing configured and fetchproxy explicitly disabled.
172
140
  throw new Error('Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), ' +
@@ -175,3 +143,96 @@ export async function resolveAuth() {
175
143
  'or install the fetchproxy extension and sign into your Canvas instance ' +
176
144
  '(unset CANVAS_DISABLE_FETCHPROXY if it is set).');
177
145
  }
146
+ /**
147
+ * One lifter per resolved tenant HOST, built lazily and reused.
148
+ *
149
+ * Keyed on the host, not the declared domain: every *.instructure.com tenant
150
+ * declares the same wildcard but holds its own host-only cookies, so a
151
+ * domain-keyed map would hand the second tenant the first one's lifter.
152
+ *
153
+ * The scope is not static — self-hosted Canvas declares its literal hostname
154
+ * while hosted tenants share the wildcard — so the lifter cannot be a
155
+ * module-level constant. Caching is load-bearing rather than an optimization:
156
+ * `createSessionLifter` single-flights concurrent calls, and that only helps
157
+ * if renewals share ONE lifter instead of constructing a fresh one per call.
158
+ */
159
+ const liftersByHost = new Map();
160
+ /**
161
+ * Split a tenant host into the declared domain and the subdomain that actually
162
+ * holds the cookies.
163
+ *
164
+ * These are NOT the same thing, and conflating them is why the lift silently
165
+ * read nothing. Canvas declares the `instructure.com` wildcard so any district
166
+ * matches without a re-pair, but `canvas_session` / `pseudonym_credentials`
167
+ * are host-only on the tenant (`cms.instructure.com`). The extension resolves
168
+ * the storage origin from domain + subdomain, so declaring the wildcard alone
169
+ * pointed the read at `https://instructure.com` — an apex that holds nothing —
170
+ * and every lift reported the user signed out while they were signed in.
171
+ *
172
+ * Returns undefined for a self-hosted install, where the host IS the domain.
173
+ */
174
+ function storageSubdomainFor(declaredDomain, baseHost) {
175
+ // Only two cases exist, because `declaredDomain` is derived FROM `baseHost`
176
+ // a few lines up: either it collapsed to the instructure.com wildcard (so
177
+ // baseHost ends with `.instructure.com`), or it is baseHost itself. A
178
+ // `endsWith` guard here would be a third branch that cannot be reached, and
179
+ // an untestable branch is worse than an asserted invariant.
180
+ if (baseHost === declaredDomain)
181
+ return undefined;
182
+ return baseHost.slice(0, -(declaredDomain.length + 1));
183
+ }
184
+ function lifterFor(declaredDomain, baseHost) {
185
+ // Key on the resolved host: two tenants share one declared domain but hold
186
+ // different cookies, so caching by domain alone would hand the second
187
+ // tenant the first one's lifter.
188
+ const subdomain = storageSubdomainFor(declaredDomain, baseHost);
189
+ let lift = liftersByHost.get(baseHost);
190
+ if (!lift) {
191
+ lift = createSessionLifter({
192
+ serverName: pkg.name,
193
+ version: pkg.version,
194
+ domains: [declaredDomain],
195
+ ...(subdomain !== undefined ? { storageSubdomain: subdomain } : {}),
196
+ declare: {
197
+ cookies: ['canvas_session', 'pseudonym_credentials'],
198
+ localStorage: [],
199
+ sessionStorage: [],
200
+ captureHeaders: [],
201
+ },
202
+ });
203
+ liftersByHost.set(baseHost, lift);
204
+ }
205
+ return lift;
206
+ }
207
+ /**
208
+ * Lift a fresh Canvas session cookie out of the user's signed-in tab.
209
+ *
210
+ * Runs on every mint, not once at startup — fetchproxy is not in the request
211
+ * hot path, only the renewal path.
212
+ */
213
+ async function liftBrowserCookie(declaredDomain, baseHost) {
214
+ try {
215
+ const session = await lifterFor(declaredDomain, baseHost)();
216
+ const canvasSession = session.cookies['canvas_session'];
217
+ const pseudoCreds = session.cookies['pseudonym_credentials'];
218
+ if (!canvasSession || !pseudoCreds) {
219
+ throw new Error(`required cookies not found on ${baseHost}. ` +
220
+ 'Sign into your Canvas instance in the browser ' +
221
+ '(with the fetchproxy extension installed) and retry.');
222
+ }
223
+ return `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
224
+ }
225
+ catch (e) {
226
+ // 0.8.0+ typed-error discrimination. The fetchproxy server already retries
227
+ // once on SW eviction, so a thrown FetchproxyBridgeDownError means the
228
+ // retry also failed — the extension's service worker is genuinely down and
229
+ // the user needs to wake it. `.hint` is the actionable copy.
230
+ if (classifyBridgeError(e) === 'bridge_down') {
231
+ const downErr = e;
232
+ throw new Error(`Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
233
+ }
234
+ const msg = e instanceof Error ? e.message : String(e);
235
+ throw new Error(`Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, ` +
236
+ `and fetchproxy lift failed: ${msg}`);
237
+ }
238
+ }
package/dist/bundle.js CHANGED
@@ -26729,17 +26729,33 @@ function normalizeObjectSchema(schema) {
26729
26729
  }
26730
26730
  return void 0;
26731
26731
  }
26732
+ function getDotPath(path) {
26733
+ if (path.length === 0) {
26734
+ return "object root";
26735
+ }
26736
+ return path.reduce((acc, seg, index) => {
26737
+ if (index === 0) {
26738
+ return String(seg);
26739
+ }
26740
+ if (typeof seg === "number") {
26741
+ return `${acc}[${seg}]`;
26742
+ }
26743
+ return `${acc}.${seg}`;
26744
+ }, "");
26745
+ }
26732
26746
  function getParseErrorMessage(error51) {
26733
26747
  if (error51 && typeof error51 === "object") {
26748
+ if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
26749
+ return error51.issues.map((i) => {
26750
+ if (!i.path?.length) {
26751
+ return i.message;
26752
+ }
26753
+ return `${i.message} at ${getDotPath(i.path)}`;
26754
+ }).join("\n");
26755
+ }
26734
26756
  if ("message" in error51 && typeof error51.message === "string") {
26735
26757
  return error51.message;
26736
26758
  }
26737
- if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
26738
- const firstIssue = error51.issues[0];
26739
- if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) {
26740
- return String(firstIssue.message);
26741
- }
26742
- }
26743
26759
  try {
26744
26760
  return JSON.stringify(error51);
26745
26761
  } catch {
@@ -33354,16 +33370,7 @@ var Server = class extends Protocol {
33354
33370
  if (!methodSchema) {
33355
33371
  throw new Error("Schema is missing a method literal");
33356
33372
  }
33357
- let methodValue;
33358
- if (isZ4Schema(methodSchema)) {
33359
- const v4Schema = methodSchema;
33360
- const v4Def = v4Schema._zod?.def;
33361
- methodValue = v4Def?.value ?? v4Schema.value;
33362
- } else {
33363
- const v3Schema = methodSchema;
33364
- const legacyDef = v3Schema._def;
33365
- methodValue = legacyDef?.value ?? v3Schema.value;
33366
- }
33373
+ const methodValue = getLiteralValue(methodSchema);
33367
33374
  if (typeof methodValue !== "string") {
33368
33375
  throw new Error("Schema method literal must be a string");
33369
33376
  }
@@ -34551,8 +34558,17 @@ var EMPTY_COMPLETION_RESULT = {
34551
34558
  import process3 from "node:process";
34552
34559
 
34553
34560
  // node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
34561
+ var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
34554
34562
  var ReadBuffer = class {
34563
+ constructor(options) {
34564
+ this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE;
34565
+ }
34555
34566
  append(chunk2) {
34567
+ const newSize = (this._buffer?.length ?? 0) + chunk2.length;
34568
+ if (newSize > this._maxBufferSize) {
34569
+ this.clear();
34570
+ throw new Error(`ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes`);
34571
+ }
34556
34572
  this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk2]) : chunk2;
34557
34573
  }
34558
34574
  readMessage() {
@@ -34580,18 +34596,24 @@ function serializeMessage(message) {
34580
34596
 
34581
34597
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
34582
34598
  var StdioServerTransport = class {
34583
- constructor(_stdin = process3.stdin, _stdout = process3.stdout) {
34599
+ constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
34584
34600
  this._stdin = _stdin;
34585
34601
  this._stdout = _stdout;
34586
- this._readBuffer = new ReadBuffer();
34587
34602
  this._started = false;
34588
34603
  this._ondata = (chunk2) => {
34589
- this._readBuffer.append(chunk2);
34590
- this.processReadBuffer();
34604
+ try {
34605
+ this._readBuffer.append(chunk2);
34606
+ this.processReadBuffer();
34607
+ } catch (error51) {
34608
+ this.onerror?.(error51);
34609
+ this.close().catch(() => {
34610
+ });
34611
+ }
34591
34612
  };
34592
34613
  this._onerror = (error51) => {
34593
34614
  this.onerror?.(error51);
34594
34615
  };
34616
+ this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize });
34595
34617
  }
34596
34618
  /**
34597
34619
  * Starts listening for messages on stdin.
@@ -38442,7 +38464,20 @@ var FetchproxyServer = class {
38442
38464
 
38443
38465
  // node_modules/@fetchproxy/bootstrap/dist/index.js
38444
38466
  var defaultFactory = (opts) => new FetchproxyServer(opts);
38445
- async function bootstrap(opts) {
38467
+ function createSessionLifter(opts) {
38468
+ let inFlight = null;
38469
+ return () => {
38470
+ if (inFlight)
38471
+ return inFlight;
38472
+ const p = runOneLift(opts).finally(() => {
38473
+ if (inFlight === p)
38474
+ inFlight = null;
38475
+ });
38476
+ inFlight = p;
38477
+ return p;
38478
+ };
38479
+ }
38480
+ async function runOneLift(opts) {
38446
38481
  const envVar = opts.serverName.toUpperCase().replace(/[^A-Z0-9]/g, "_").replace(/^_+/, "") + "_DISABLE_FETCHPROXY";
38447
38482
  const envVal = process.env[envVar];
38448
38483
  if (envVal !== void 0 && envVal !== "" && envVal !== "0" && envVal !== "false") {
@@ -38569,12 +38604,18 @@ async function bootstrap(opts) {
38569
38604
  });
38570
38605
  indexedDbBucket[`${d.database}/${d.store}`] = values;
38571
38606
  }
38607
+ const absent = (declared, got) => declared.filter((k) => !(k in got));
38572
38608
  return {
38573
38609
  cookies,
38574
38610
  localStorage,
38575
38611
  sessionStorage,
38576
38612
  capturedHeaders,
38577
- indexedDb: indexedDbBucket
38613
+ indexedDb: indexedDbBucket,
38614
+ missing: {
38615
+ cookies: absent(opts.declare.cookies, cookies),
38616
+ localStorage: absent(opts.declare.localStorage, localStorage),
38617
+ sessionStorage: absent(opts.declare.sessionStorage, sessionStorage)
38618
+ }
38578
38619
  };
38579
38620
  } finally {
38580
38621
  try {
@@ -38679,7 +38720,7 @@ function loadAccount(env = process.env) {
38679
38720
  // package.json
38680
38721
  var package_default = {
38681
38722
  name: "canvas-parent-mcp",
38682
- version: "1.2.2",
38723
+ version: "1.2.4",
38683
38724
  mcpName: "io.github.chrischall/canvas-parent-mcp",
38684
38725
  description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
38685
38726
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38724,7 +38765,7 @@ var package_default = {
38724
38765
  },
38725
38766
  dependencies: {
38726
38767
  "@chrischall/mcp-utils": "^0.14.0",
38727
- "@fetchproxy/bootstrap": "^1.7.0",
38768
+ "@fetchproxy/bootstrap": "^1.9.1",
38728
38769
  "@fetchproxy/server": "^1.7.0",
38729
38770
  "@modelcontextprotocol/sdk": "^1.29.0",
38730
38771
  dotenv: "^17.4.2",
@@ -38764,54 +38805,71 @@ async function resolveAuth() {
38764
38805
  const declaredDomain = baseHost.endsWith(".instructure.com") ? "instructure.com" : baseHost;
38765
38806
  const name = readEnv("CANVAS_NAME") ?? baseHost;
38766
38807
  const cleanBaseUrl = baseUrl.replace(/\/$/, "");
38767
- try {
38768
- const session = await bootstrap({
38769
- serverName: package_default.name,
38770
- version: package_default.version,
38771
- domains: [declaredDomain],
38772
- declare: {
38773
- cookies: ["canvas_session", "pseudonym_credentials"],
38774
- localStorage: [],
38775
- sessionStorage: [],
38776
- captureHeaders: []
38777
- }
38778
- });
38779
- const canvasSession = session.cookies["canvas_session"];
38780
- const pseudoCreds = session.cookies["pseudonym_credentials"];
38781
- if (!canvasSession || !pseudoCreds) {
38782
- throw new Error(
38783
- `required cookies not found on ${baseHost}. Sign into your Canvas instance in the browser (with the fetchproxy extension installed) and retry.`
38784
- );
38785
- }
38786
- const cookie = `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
38787
- const account = {
38788
- mode: "session",
38789
- name,
38790
- baseUrl: cleanBaseUrl,
38791
- username: "",
38792
- password: ""
38793
- };
38794
- return {
38795
- account,
38796
- preloaded: { cookie },
38797
- source: "fetchproxy"
38798
- };
38799
- } catch (e) {
38800
- if (classifyBridgeError(e) === "bridge_down") {
38801
- const downErr = e;
38802
- throw new Error(
38803
- `Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
38804
- );
38808
+ const account = {
38809
+ mode: "session",
38810
+ name,
38811
+ baseUrl: cleanBaseUrl,
38812
+ username: "",
38813
+ password: ""
38814
+ };
38815
+ return {
38816
+ account,
38817
+ refresh: () => liftBrowserCookie(declaredDomain, baseHost),
38818
+ source: "fetchproxy"
38819
+ };
38820
+ }
38821
+ throw new Error(
38822
+ "Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN (OAuth), or install the fetchproxy extension and sign into your Canvas instance (unset CANVAS_DISABLE_FETCHPROXY if it is set)."
38823
+ );
38824
+ }
38825
+ var liftersByHost = /* @__PURE__ */ new Map();
38826
+ function storageSubdomainFor(declaredDomain, baseHost) {
38827
+ if (baseHost === declaredDomain) return void 0;
38828
+ return baseHost.slice(0, -(declaredDomain.length + 1));
38829
+ }
38830
+ function lifterFor(declaredDomain, baseHost) {
38831
+ const subdomain = storageSubdomainFor(declaredDomain, baseHost);
38832
+ let lift = liftersByHost.get(baseHost);
38833
+ if (!lift) {
38834
+ lift = createSessionLifter({
38835
+ serverName: package_default.name,
38836
+ version: package_default.version,
38837
+ domains: [declaredDomain],
38838
+ ...subdomain !== void 0 ? { storageSubdomain: subdomain } : {},
38839
+ declare: {
38840
+ cookies: ["canvas_session", "pseudonym_credentials"],
38841
+ localStorage: [],
38842
+ sessionStorage: [],
38843
+ captureHeaders: []
38805
38844
  }
38806
- const msg = e instanceof Error ? e.message : String(e);
38845
+ });
38846
+ liftersByHost.set(baseHost, lift);
38847
+ }
38848
+ return lift;
38849
+ }
38850
+ async function liftBrowserCookie(declaredDomain, baseHost) {
38851
+ try {
38852
+ const session = await lifterFor(declaredDomain, baseHost)();
38853
+ const canvasSession = session.cookies["canvas_session"];
38854
+ const pseudoCreds = session.cookies["pseudonym_credentials"];
38855
+ if (!canvasSession || !pseudoCreds) {
38856
+ throw new Error(
38857
+ `required cookies not found on ${baseHost}. Sign into your Canvas instance in the browser (with the fetchproxy extension installed) and retry.`
38858
+ );
38859
+ }
38860
+ return `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
38861
+ } catch (e) {
38862
+ if (classifyBridgeError(e) === "bridge_down") {
38863
+ const downErr = e;
38807
38864
  throw new Error(
38808
- `Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, and fetchproxy fallback failed: ${msg}`
38865
+ `Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
38809
38866
  );
38810
38867
  }
38868
+ const msg = e instanceof Error ? e.message : String(e);
38869
+ throw new Error(
38870
+ `Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, and fetchproxy lift failed: ${msg}`
38871
+ );
38811
38872
  }
38812
- throw new Error(
38813
- "Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN (OAuth), or install the fetchproxy extension and sign into your Canvas instance (unset CANVAS_DISABLE_FETCHPROXY if it is set)."
38814
- );
38815
38873
  }
38816
38874
 
38817
38875
  // src/client.ts
@@ -39049,28 +39107,35 @@ var DEFAULT_MAX_PAGES = 50;
39049
39107
  var CanvasClient = class {
39050
39108
  account;
39051
39109
  sessionLoginFn;
39052
- preloadedCookie;
39110
+ /**
39111
+ * fetchproxy lift. Called on the first login AND on every renewal, which is
39112
+ * what keeps the path alive once the browser cookie lapses.
39113
+ */
39114
+ refreshSession;
39053
39115
  auth;
39054
39116
  /** Lazily-built shared refresh_token exchanger (oauth mode only). */
39055
39117
  oauthRefresh = null;
39056
39118
  /**
39057
- * `preloaded` is the fetchproxy escape hatch: when set, the client uses
39058
- * the supplied cookie header as-if it had just successfully run
39059
- * `sessionLogin()`. On a 401 it falls back to the lazy login flow only if
39060
- * usable credentials are present on the account — otherwise the 401
39061
- * surfaces as a TokenExpiredError (re-sign-in happens in the browser, not
39062
- * by re-running a form login with empty creds).
39119
+ * `refreshSession` is the fetchproxy escape hatch: when set, it REPLACES
39120
+ * `sessionLogin()` as the way this client mints a session cookie. It is
39121
+ * called lazily on the first request and again on every 401, so a lapsed
39122
+ * browser cookie recovers by re-reading the tab instead of dead-ending.
39123
+ *
39124
+ * It must re-read the browser each time rather than return a captured
39125
+ * value — that capture-once shape is precisely what made a 401 terminal
39126
+ * here, with a restart the only cure.
39063
39127
  */
39064
39128
  constructor(account, opts = {}) {
39065
39129
  this.account = account;
39066
39130
  this.sessionLoginFn = opts.sessionLogin ?? sessionLogin;
39067
- this.preloadedCookie = opts.preloaded?.cookie ?? null;
39131
+ this.refreshSession = opts.refreshSession ?? null;
39068
39132
  this.auth = new CookieSessionManager({
39069
39133
  login: () => this.login(),
39070
- // Reactive expiry: only a 401 in a mode that can re-mint warrants a replay.
39071
- // token mode and fetchproxy-session (empty creds) can't re-auth here — their
39072
- // 401 falls through as a Response and `mapStatus` turns it into a
39073
- // TokenExpiredError so the user is told to re-sign-in / re-config.
39134
+ // Reactive expiry: only a 401 in a mode that can re-mint warrants a
39135
+ // replay. token mode still can't (no refresh path), and a session
39136
+ // account with neither a lift nor credentials still can't — those 401s
39137
+ // fall through as a Response and `mapStatus` turns them into a
39138
+ // TokenExpiredError.
39074
39139
  isExpired: (res) => res.status === 401 && this.canReauth()
39075
39140
  });
39076
39141
  }
@@ -39178,7 +39243,7 @@ var CanvasClient = class {
39178
39243
  canReauth() {
39179
39244
  const acct = this.account;
39180
39245
  if (acct.mode === "oauth") return true;
39181
- if (acct.mode === "session") return !!acct.username && !!acct.password;
39246
+ if (acct.mode === "session") return !!this.refreshSession || !!acct.username && !!acct.password;
39182
39247
  return false;
39183
39248
  }
39184
39249
  /** Mint the auth credential for the current mode. Invoked by the manager. */
@@ -39188,8 +39253,8 @@ var CanvasClient = class {
39188
39253
  return { headers: { Authorization: `Bearer ${acct.token}` } };
39189
39254
  }
39190
39255
  if (acct.mode === "session") {
39191
- if (this.preloadedCookie !== null) {
39192
- return { headers: { Cookie: this.preloadedCookie } };
39256
+ if (this.refreshSession !== null) {
39257
+ return { headers: { Cookie: await this.refreshSession() } };
39193
39258
  }
39194
39259
  return { headers: { Cookie: await this.mintSessionCookie(acct) } };
39195
39260
  }
@@ -39655,13 +39720,13 @@ try {
39655
39720
  }
39656
39721
  await runMcp({
39657
39722
  name: "canvas",
39658
- version: "1.2.2",
39723
+ version: "1.2.4",
39659
39724
  // x-release-please-version
39660
39725
  tools: [
39661
39726
  (server) => {
39662
39727
  if (resolved) {
39663
39728
  const client = new CanvasClient(resolved.account, {
39664
- preloaded: resolved.preloaded
39729
+ refreshSession: resolved.refresh
39665
39730
  });
39666
39731
  registerProfileTools(server, client);
39667
39732
  registerObserveeTools(server, client);
package/dist/client.js CHANGED
@@ -13,28 +13,35 @@ const DEFAULT_MAX_PAGES = 50;
13
13
  export class CanvasClient {
14
14
  account;
15
15
  sessionLoginFn;
16
- preloadedCookie;
16
+ /**
17
+ * fetchproxy lift. Called on the first login AND on every renewal, which is
18
+ * what keeps the path alive once the browser cookie lapses.
19
+ */
20
+ refreshSession;
17
21
  auth;
18
22
  /** Lazily-built shared refresh_token exchanger (oauth mode only). */
19
23
  oauthRefresh = null;
20
24
  /**
21
- * `preloaded` is the fetchproxy escape hatch: when set, the client uses
22
- * the supplied cookie header as-if it had just successfully run
23
- * `sessionLogin()`. On a 401 it falls back to the lazy login flow only if
24
- * usable credentials are present on the account — otherwise the 401
25
- * surfaces as a TokenExpiredError (re-sign-in happens in the browser, not
26
- * by re-running a form login with empty creds).
25
+ * `refreshSession` is the fetchproxy escape hatch: when set, it REPLACES
26
+ * `sessionLogin()` as the way this client mints a session cookie. It is
27
+ * called lazily on the first request and again on every 401, so a lapsed
28
+ * browser cookie recovers by re-reading the tab instead of dead-ending.
29
+ *
30
+ * It must re-read the browser each time rather than return a captured
31
+ * value — that capture-once shape is precisely what made a 401 terminal
32
+ * here, with a restart the only cure.
27
33
  */
28
34
  constructor(account, opts = {}) {
29
35
  this.account = account;
30
36
  this.sessionLoginFn = opts.sessionLogin ?? defaultSessionLogin;
31
- this.preloadedCookie = opts.preloaded?.cookie ?? null;
37
+ this.refreshSession = opts.refreshSession ?? null;
32
38
  this.auth = new CookieSessionManager({
33
39
  login: () => this.login(),
34
- // Reactive expiry: only a 401 in a mode that can re-mint warrants a replay.
35
- // token mode and fetchproxy-session (empty creds) can't re-auth here — their
36
- // 401 falls through as a Response and `mapStatus` turns it into a
37
- // TokenExpiredError so the user is told to re-sign-in / re-config.
40
+ // Reactive expiry: only a 401 in a mode that can re-mint warrants a
41
+ // replay. token mode still can't (no refresh path), and a session
42
+ // account with neither a lift nor credentials still can't — those 401s
43
+ // fall through as a Response and `mapStatus` turns them into a
44
+ // TokenExpiredError.
38
45
  isExpired: (res) => res.status === 401 && this.canReauth(),
39
46
  });
40
47
  }
@@ -157,11 +164,12 @@ export class CanvasClient {
157
164
  const acct = this.account;
158
165
  if (acct.mode === 'oauth')
159
166
  return true;
160
- // session: only when we hold real form credentials. The fetchproxy path
161
- // synthesizes a SessionAccount with empty username/password and a preloaded
162
- // cookie — it can't re-mint, so its 401 is terminal.
167
+ // session: either a browser lift (fetchproxy) or real form credentials
168
+ // can re-mint. The lift is the fetchproxy path's re-auth — before it
169
+ // existed, that path synthesized empty username/password and a captured
170
+ // cookie, so its 401 was terminal and only a restart recovered.
163
171
  if (acct.mode === 'session')
164
- return !!acct.username && !!acct.password;
172
+ return !!this.refreshSession || (!!acct.username && !!acct.password);
165
173
  return false; // token mode: no refresh path.
166
174
  }
167
175
  /** Mint the auth credential for the current mode. Invoked by the manager. */
@@ -171,11 +179,10 @@ export class CanvasClient {
171
179
  return { headers: { Authorization: `Bearer ${acct.token}` } };
172
180
  }
173
181
  if (acct.mode === 'session') {
174
- // fetchproxy path: a preloaded cookie stands in for a form login on the
175
- // first ensure(). (A 401 here is terminal — canReauth() is false — so the
176
- // manager never asks login() to re-mint with empty creds.)
177
- if (this.preloadedCookie !== null) {
178
- return { headers: { Cookie: this.preloadedCookie } };
182
+ // fetchproxy path: re-read the browser instead of posting a form login.
183
+ // Runs on every mint, not just the first, so an expiry renews.
184
+ if (this.refreshSession !== null) {
185
+ return { headers: { Cookie: await this.refreshSession() } };
179
186
  }
180
187
  return { headers: { Cookie: await this.mintSessionCookie(acct) } };
181
188
  }
package/dist/index.js CHANGED
@@ -46,12 +46,12 @@ catch (e) {
46
46
  // host's install-time `tools/list` succeeds.
47
47
  await runMcp({
48
48
  name: 'canvas',
49
- version: '1.2.2', // x-release-please-version
49
+ version: '1.2.4', // x-release-please-version
50
50
  tools: [
51
51
  (server) => {
52
52
  if (resolved) {
53
53
  const client = new CanvasClient(resolved.account, {
54
- preloaded: resolved.preloaded,
54
+ refreshSession: resolved.refresh,
55
55
  });
56
56
  registerProfileTools(server, client);
57
57
  registerObserveeTools(server, client);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "mcpName": "io.github.chrischall/canvas-parent-mcp",
5
5
  "description": "Canvas LMS (Instructure) MCP server — student/observer read access with optional file download",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@chrischall/mcp-utils": "^0.14.0",
48
- "@fetchproxy/bootstrap": "^1.7.0",
48
+ "@fetchproxy/bootstrap": "^1.9.1",
49
49
  "@fetchproxy/server": "^1.7.0",
50
50
  "@modelcontextprotocol/sdk": "^1.29.0",
51
51
  "dotenv": "^17.4.2",
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.2.2",
9
+ "version": "1.2.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "1.2.2",
14
+ "version": "1.2.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },