canvas-parent-mcp 1.2.0 → 1.2.3

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.0"
10
+ "version": "1.2.3"
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.0",
18
+ "version": "1.2.3",
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.0",
4
+ "version": "1.2.3",
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,64 @@ 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 declared domain, built lazily and reused.
148
+ *
149
+ * The scope is not static — self-hosted Canvas declares its literal hostname
150
+ * while *.instructure.com tenants share one wildcard — so the lifter cannot be
151
+ * a module-level constant. Caching is load-bearing rather than an
152
+ * optimization: `createSessionLifter` single-flights concurrent calls, and
153
+ * that only helps if renewals share ONE lifter instead of constructing a fresh
154
+ * one per call.
155
+ */
156
+ const liftersByDomain = new Map();
157
+ function lifterFor(declaredDomain) {
158
+ let lift = liftersByDomain.get(declaredDomain);
159
+ if (!lift) {
160
+ lift = createSessionLifter({
161
+ serverName: pkg.name,
162
+ version: pkg.version,
163
+ domains: [declaredDomain],
164
+ declare: {
165
+ cookies: ['canvas_session', 'pseudonym_credentials'],
166
+ localStorage: [],
167
+ sessionStorage: [],
168
+ captureHeaders: [],
169
+ },
170
+ });
171
+ liftersByDomain.set(declaredDomain, lift);
172
+ }
173
+ return lift;
174
+ }
175
+ /**
176
+ * Lift a fresh Canvas session cookie out of the user's signed-in tab.
177
+ *
178
+ * Runs on every mint, not once at startup — fetchproxy is not in the request
179
+ * hot path, only the renewal path.
180
+ */
181
+ async function liftBrowserCookie(declaredDomain, baseHost) {
182
+ try {
183
+ const session = await lifterFor(declaredDomain)();
184
+ const canvasSession = session.cookies['canvas_session'];
185
+ const pseudoCreds = session.cookies['pseudonym_credentials'];
186
+ if (!canvasSession || !pseudoCreds) {
187
+ throw new Error(`required cookies not found on ${baseHost}. ` +
188
+ 'Sign into your Canvas instance in the browser ' +
189
+ '(with the fetchproxy extension installed) and retry.');
190
+ }
191
+ return `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
192
+ }
193
+ catch (e) {
194
+ // 0.8.0+ typed-error discrimination. The fetchproxy server already retries
195
+ // once on SW eviction, so a thrown FetchproxyBridgeDownError means the
196
+ // retry also failed — the extension's service worker is genuinely down and
197
+ // the user needs to wake it. `.hint` is the actionable copy.
198
+ if (classifyBridgeError(e) === 'bridge_down') {
199
+ const downErr = e;
200
+ throw new Error(`Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`);
201
+ }
202
+ const msg = e instanceof Error ? e.message : String(e);
203
+ throw new Error(`Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, ` +
204
+ `and fetchproxy lift failed: ${msg}`);
205
+ }
206
+ }
package/dist/bundle.js CHANGED
@@ -3776,6 +3776,7 @@ var require_fast_uri = __commonJS({
3776
3776
  return uriTokens.join("");
3777
3777
  }
3778
3778
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3779
+ var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
3779
3780
  function getParseError(parsed, matches) {
3780
3781
  if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3781
3782
  return 'URI path must start with "/" when authority is present.';
@@ -3805,6 +3806,11 @@ var require_fast_uri = __commonJS({
3805
3806
  uri = "//" + uri;
3806
3807
  }
3807
3808
  }
3809
+ const authorityMatch = uri.match(AUTHORITY_PREFIX);
3810
+ if (authorityMatch !== null && authorityMatch[1].indexOf("\\") !== -1) {
3811
+ parsed.error = "URI authority must not contain a literal backslash.";
3812
+ malformedAuthorityOrPort = true;
3813
+ }
3808
3814
  const matches = uri.match(URI_PARSE);
3809
3815
  if (matches) {
3810
3816
  parsed.scheme = matches[1];
@@ -3848,7 +3854,7 @@ var require_fast_uri = __commonJS({
3848
3854
  if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
3849
3855
  if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
3850
3856
  try {
3851
- parsed.host = URL.domainToASCII(parsed.host.toLowerCase());
3857
+ parsed.host = new URL("http://" + parsed.host).hostname;
3852
3858
  } catch (e) {
3853
3859
  parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e;
3854
3860
  }
@@ -7693,6 +7699,7 @@ var require_receiver = __commonJS({
7693
7699
  this._opcode = 0;
7694
7700
  this._totalPayloadLength = 0;
7695
7701
  this._messageLength = 0;
7702
+ this._numFragments = 0;
7696
7703
  this._fragments = [];
7697
7704
  this._errored = false;
7698
7705
  this._loop = false;
@@ -8043,23 +8050,23 @@ var require_receiver = __commonJS({
8043
8050
  this.controlMessage(data, cb);
8044
8051
  return;
8045
8052
  }
8053
+ if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) {
8054
+ const error51 = this.createError(
8055
+ RangeError,
8056
+ "Too many message fragments",
8057
+ false,
8058
+ 1008,
8059
+ "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8060
+ );
8061
+ cb(error51);
8062
+ return;
8063
+ }
8046
8064
  if (this._compressed) {
8047
8065
  this._state = INFLATING;
8048
8066
  this.decompress(data, cb);
8049
8067
  return;
8050
8068
  }
8051
8069
  if (data.length) {
8052
- if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
8053
- const error51 = this.createError(
8054
- RangeError,
8055
- "Too many message fragments",
8056
- false,
8057
- 1008,
8058
- "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8059
- );
8060
- cb(error51);
8061
- return;
8062
- }
8063
8070
  this._messageLength = this._totalPayloadLength;
8064
8071
  this._fragments.push(data);
8065
8072
  }
@@ -8089,17 +8096,6 @@ var require_receiver = __commonJS({
8089
8096
  cb(error51);
8090
8097
  return;
8091
8098
  }
8092
- if (this._maxFragments > 0 && this._fragments.length >= this._maxFragments) {
8093
- const error51 = this.createError(
8094
- RangeError,
8095
- "Too many message fragments",
8096
- false,
8097
- 1008,
8098
- "WS_ERR_TOO_MANY_BUFFERED_PARTS"
8099
- );
8100
- cb(error51);
8101
- return;
8102
- }
8103
8099
  this._fragments.push(buf);
8104
8100
  }
8105
8101
  this.dataMessage(cb);
@@ -8122,6 +8118,7 @@ var require_receiver = __commonJS({
8122
8118
  this._totalPayloadLength = 0;
8123
8119
  this._messageLength = 0;
8124
8120
  this._fragmented = 0;
8121
+ this._numFragments = 0;
8125
8122
  this._fragments = [];
8126
8123
  if (this._opcode === 2) {
8127
8124
  let data;
@@ -9622,8 +9619,8 @@ var require_websocket = __commonJS({
9622
9619
  autoPong: true,
9623
9620
  closeTimeout: CLOSE_TIMEOUT,
9624
9621
  protocolVersion: protocolVersions[1],
9625
- maxBufferedChunks: 1024 * 1024,
9626
- maxFragments: 128 * 1024,
9622
+ maxBufferedChunks: 256 * 1024,
9623
+ maxFragments: 16 * 1024,
9627
9624
  maxPayload: 100 * 1024 * 1024,
9628
9625
  skipUTF8Validation: false,
9629
9626
  perMessageDeflate: true,
@@ -10210,9 +10207,9 @@ var require_websocket_server = __commonJS({
10210
10207
  * called
10211
10208
  * @param {Function} [options.handleProtocols] A hook to handle protocols
10212
10209
  * @param {String} [options.host] The hostname where to bind the server
10213
- * @param {Number} [options.maxBufferedChunks=1048576] The maximum number of
10210
+ * @param {Number} [options.maxBufferedChunks=262144] The maximum number of
10214
10211
  * buffered data chunks
10215
- * @param {Number} [options.maxFragments=131072] The maximum number of message
10212
+ * @param {Number} [options.maxFragments=16384] The maximum number of message
10216
10213
  * fragments
10217
10214
  * @param {Number} [options.maxPayload=104857600] The maximum allowed message
10218
10215
  * size
@@ -10235,8 +10232,8 @@ var require_websocket_server = __commonJS({
10235
10232
  options = {
10236
10233
  allowSynchronousEvents: true,
10237
10234
  autoPong: true,
10238
- maxBufferedChunks: 1024 * 1024,
10239
- maxFragments: 128 * 1024,
10235
+ maxBufferedChunks: 256 * 1024,
10236
+ maxFragments: 16 * 1024,
10240
10237
  maxPayload: 100 * 1024 * 1024,
10241
10238
  skipUTF8Validation: false,
10242
10239
  perMessageDeflate: false,
@@ -26732,17 +26729,33 @@ function normalizeObjectSchema(schema) {
26732
26729
  }
26733
26730
  return void 0;
26734
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
+ }
26735
26746
  function getParseErrorMessage(error51) {
26736
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
+ }
26737
26756
  if ("message" in error51 && typeof error51.message === "string") {
26738
26757
  return error51.message;
26739
26758
  }
26740
- if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
26741
- const firstIssue = error51.issues[0];
26742
- if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) {
26743
- return String(firstIssue.message);
26744
- }
26745
- }
26746
26759
  try {
26747
26760
  return JSON.stringify(error51);
26748
26761
  } catch {
@@ -33357,16 +33370,7 @@ var Server = class extends Protocol {
33357
33370
  if (!methodSchema) {
33358
33371
  throw new Error("Schema is missing a method literal");
33359
33372
  }
33360
- let methodValue;
33361
- if (isZ4Schema(methodSchema)) {
33362
- const v4Schema = methodSchema;
33363
- const v4Def = v4Schema._zod?.def;
33364
- methodValue = v4Def?.value ?? v4Schema.value;
33365
- } else {
33366
- const v3Schema = methodSchema;
33367
- const legacyDef = v3Schema._def;
33368
- methodValue = legacyDef?.value ?? v3Schema.value;
33369
- }
33373
+ const methodValue = getLiteralValue(methodSchema);
33370
33374
  if (typeof methodValue !== "string") {
33371
33375
  throw new Error("Schema method literal must be a string");
33372
33376
  }
@@ -34554,8 +34558,17 @@ var EMPTY_COMPLETION_RESULT = {
34554
34558
  import process3 from "node:process";
34555
34559
 
34556
34560
  // node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
34561
+ var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
34557
34562
  var ReadBuffer = class {
34563
+ constructor(options) {
34564
+ this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE;
34565
+ }
34558
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
+ }
34559
34572
  this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk2]) : chunk2;
34560
34573
  }
34561
34574
  readMessage() {
@@ -34583,18 +34596,24 @@ function serializeMessage(message) {
34583
34596
 
34584
34597
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
34585
34598
  var StdioServerTransport = class {
34586
- constructor(_stdin = process3.stdin, _stdout = process3.stdout) {
34599
+ constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
34587
34600
  this._stdin = _stdin;
34588
34601
  this._stdout = _stdout;
34589
- this._readBuffer = new ReadBuffer();
34590
34602
  this._started = false;
34591
34603
  this._ondata = (chunk2) => {
34592
- this._readBuffer.append(chunk2);
34593
- 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
+ }
34594
34612
  };
34595
34613
  this._onerror = (error51) => {
34596
34614
  this.onerror?.(error51);
34597
34615
  };
34616
+ this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize });
34598
34617
  }
34599
34618
  /**
34600
34619
  * Starts listening for messages on stdin.
@@ -34990,7 +35009,8 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34990
35009
  "capture_redirect",
34991
35010
  "read_indexed_db",
34992
35011
  "read_dom",
34993
- "download"
35012
+ "download",
35013
+ "graphql"
34994
35014
  ]);
34995
35015
 
34996
35016
  // node_modules/@fetchproxy/protocol/dist/mcp-id.js
@@ -35293,6 +35313,7 @@ function assertIndexedDbScopesArray(value, label) {
35293
35313
  }
35294
35314
  var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35295
35315
  var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35316
+ var GRAPHQL_OP_NAME_RE = /^[_A-Za-z][_0-9A-Za-z]{0,127}$/;
35296
35317
  function assertDomSelectorsArray(value, label) {
35297
35318
  if (!Array.isArray(value)) {
35298
35319
  throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
@@ -35329,6 +35350,37 @@ function assertDomSelectorsArray(value, label) {
35329
35350
  }
35330
35351
  }
35331
35352
  }
35353
+ function assertGraphqlOpsArray(value, label) {
35354
+ if (!Array.isArray(value)) {
35355
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35356
+ }
35357
+ const seen = /* @__PURE__ */ new Set();
35358
+ for (let i = 0; i < value.length; i++) {
35359
+ const entry = value[i];
35360
+ assertObject(entry, `${label}[${i}]`);
35361
+ if (entry.name === void 0) {
35362
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35363
+ }
35364
+ if (entry.operationName === void 0) {
35365
+ throw new ProtocolError(`${label}[${i}].operationName: missing`);
35366
+ }
35367
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35368
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35369
+ }
35370
+ if (typeof entry.operationName !== "string" || !GRAPHQL_OP_NAME_RE.test(entry.operationName)) {
35371
+ throw new ProtocolError(`${label}[${i}].operationName: invalid ${JSON.stringify(entry.operationName)}`);
35372
+ }
35373
+ if (seen.has(entry.name)) {
35374
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35375
+ }
35376
+ seen.add(entry.name);
35377
+ for (const k of Object.keys(entry)) {
35378
+ if (k !== "name" && k !== "operationName") {
35379
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35380
+ }
35381
+ }
35382
+ }
35383
+ }
35332
35384
  function validateFrame(raw) {
35333
35385
  assertObject(raw, "frame");
35334
35386
  const t = raw.type;
@@ -35407,6 +35459,9 @@ function validateHello(raw) {
35407
35459
  if (raw.domSelectors !== void 0) {
35408
35460
  assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35409
35461
  }
35462
+ if (raw.graphqlOps !== void 0) {
35463
+ assertGraphqlOpsArray(raw.graphqlOps, "hello.graphqlOps");
35464
+ }
35410
35465
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35411
35466
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35412
35467
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35656,6 +35711,28 @@ function validateInnerRequest(raw) {
35656
35711
  }
35657
35712
  return raw;
35658
35713
  }
35714
+ if (raw.op === "graphql_query") {
35715
+ assertObject(raw.init, "inner.init");
35716
+ if (raw.init.name === void 0)
35717
+ throw new ProtocolError("inner.init.name: missing");
35718
+ if (raw.init.variables === void 0) {
35719
+ throw new ProtocolError("inner.init.variables: missing");
35720
+ }
35721
+ assertString(raw.init.name, "inner.init.name");
35722
+ if (raw.init.name.length === 0) {
35723
+ throw new ProtocolError("inner.init.name: must be non-empty");
35724
+ }
35725
+ assertObject(raw.init.variables, "inner.init.variables");
35726
+ if (raw.init.tabUrl !== void 0) {
35727
+ assertString(raw.init.tabUrl, "inner.init.tabUrl");
35728
+ }
35729
+ for (const k of Object.keys(raw.init)) {
35730
+ if (k !== "name" && k !== "variables" && k !== "tabUrl") {
35731
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on graphql_query`);
35732
+ }
35733
+ }
35734
+ return raw;
35735
+ }
35659
35736
  if (raw.op === "download") {
35660
35737
  assertObject(raw.init, "inner.init");
35661
35738
  if (raw.init.url === void 0) {
@@ -35681,7 +35758,7 @@ function validateInnerRequest(raw) {
35681
35758
  }
35682
35759
  return raw;
35683
35760
  }
35684
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
35761
+ throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download", "graphql_query"; got ${JSON.stringify(raw.op)}`);
35685
35762
  }
35686
35763
  function assertNonEmptyKeyArray(value, label) {
35687
35764
  if (!Array.isArray(value)) {
@@ -35712,6 +35789,10 @@ function assertStringMap(value, label) {
35712
35789
  }
35713
35790
  }
35714
35791
  }
35792
+ var KNOWN_RESPONSE_OPS = /* @__PURE__ */ new Set([
35793
+ ...KNOWN_CAPABILITIES,
35794
+ "graphql_query"
35795
+ ]);
35715
35796
  function validateInnerResponse(raw) {
35716
35797
  assertPositiveInt(raw.id, "inner.id");
35717
35798
  if (raw.ok === true) {
@@ -35773,6 +35854,13 @@ function validateInnerResponse(raw) {
35773
35854
  assertStringMap(raw.values, "inner.values");
35774
35855
  return raw;
35775
35856
  }
35857
+ if (op === "graphql_query") {
35858
+ if (raw.data === void 0) {
35859
+ throw new ProtocolError("inner.data: missing on graphql_query response");
35860
+ }
35861
+ assertObject(raw.data, "inner.data");
35862
+ return raw;
35863
+ }
35776
35864
  if (op === "download") {
35777
35865
  assertObject(raw.value, "inner.value");
35778
35866
  assertString(raw.value.path, "inner.value.path");
@@ -35797,7 +35885,7 @@ function validateInnerResponse(raw) {
35797
35885
  if (raw.ok === false) {
35798
35886
  assertString(raw.error, "inner.error");
35799
35887
  if (raw.op !== void 0) {
35800
- if (typeof raw.op !== "string" || !KNOWN_CAPABILITIES.has(raw.op)) {
35888
+ if (typeof raw.op !== "string" || !KNOWN_RESPONSE_OPS.has(raw.op)) {
35801
35889
  throw new ProtocolError(`inner.op: unknown response op ${JSON.stringify(raw.op)}`);
35802
35890
  }
35803
35891
  }
@@ -35989,11 +36077,33 @@ async function sealInnerFrame(sessionKey, mcpId, seq, inner) {
35989
36077
  };
35990
36078
  }
35991
36079
  async function openEncryptedFrame(sessionKey, frame) {
35992
- const iv = fromB64(frame.iv);
35993
- const ct = fromB64(frame.ciphertext);
35994
- const pt = await aesGcmOpen(sessionKey, iv, ct);
35995
- const parsed = JSON.parse(dec.decode(pt));
35996
- return validateInnerFrame(parsed);
36080
+ const result = await openEncryptedFrameDetailed(sessionKey, frame);
36081
+ if (result.stage === "ok")
36082
+ return result.inner;
36083
+ throw result.error instanceof Error ? result.error : new Error(String(result.error));
36084
+ }
36085
+ async function openEncryptedFrameDetailed(sessionKey, frame) {
36086
+ let pt;
36087
+ try {
36088
+ const iv = fromB64(frame.iv);
36089
+ const ct = fromB64(frame.ciphertext);
36090
+ pt = await aesGcmOpen(sessionKey, iv, ct);
36091
+ } catch (error51) {
36092
+ return { stage: "decrypt-failed", error: error51 };
36093
+ }
36094
+ let parsed;
36095
+ try {
36096
+ parsed = JSON.parse(dec.decode(pt));
36097
+ } catch (error51) {
36098
+ return { stage: "validation-failed", error: error51, recoveredId: void 0 };
36099
+ }
36100
+ try {
36101
+ const inner = validateInnerFrame(parsed);
36102
+ return { stage: "ok", inner };
36103
+ } catch (error51) {
36104
+ const recoveredId = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && typeof parsed.id === "number" && Number.isInteger(parsed.id) && parsed.id > 0 ? parsed.id : void 0;
36105
+ return { stage: "validation-failed", error: error51, recoveredId };
36106
+ }
35997
36107
  }
35998
36108
 
35999
36109
  // node_modules/@fetchproxy/server/dist/election.js
@@ -36119,6 +36229,12 @@ async function buildServerHello(opts) {
36119
36229
  ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36120
36230
  }));
36121
36231
  }
36232
+ if (opts.graphqlOps && opts.graphqlOps.length > 0) {
36233
+ hello.graphqlOps = opts.graphqlOps.map((d) => ({
36234
+ name: d.name,
36235
+ operationName: d.operationName
36236
+ }));
36237
+ }
36122
36238
  return hello;
36123
36239
  }
36124
36240
 
@@ -36209,7 +36325,8 @@ async function startHost(opts) {
36209
36325
  indexedDbScopes: opts.ownIndexedDbScopes,
36210
36326
  localStoragePointers: opts.ownLocalStoragePointers,
36211
36327
  sessionStoragePointers: opts.ownSessionStoragePointers,
36212
- domSelectors: opts.ownDomSelectors
36328
+ domSelectors: opts.ownDomSelectors,
36329
+ graphqlOps: opts.ownGraphqlOps
36213
36330
  });
36214
36331
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
36215
36332
  let extensionWs = null;
@@ -36446,7 +36563,8 @@ async function startPeer(opts) {
36446
36563
  indexedDbScopes: opts.indexedDbScopes,
36447
36564
  domSelectors: opts.domSelectors,
36448
36565
  localStoragePointers: opts.localStoragePointers,
36449
- sessionStoragePointers: opts.sessionStoragePointers
36566
+ sessionStoragePointers: opts.sessionStoragePointers,
36567
+ graphqlOps: opts.graphqlOps
36450
36568
  });
36451
36569
  const sessionNonce = fromB64(hello.sessionNonce);
36452
36570
  ws.send(JSON.stringify(hello));
@@ -36490,10 +36608,20 @@ async function startPeer(opts) {
36490
36608
  return;
36491
36609
  if (!session.acceptInboundSeq(frame.seq))
36492
36610
  return;
36493
- try {
36494
- const inner = await openEncryptedFrame(session.sessionKey, frame);
36495
- innerListeners.forEach((cb) => cb(inner));
36496
- } catch {
36611
+ const result = await openEncryptedFrameDetailed(session.sessionKey, frame);
36612
+ if (result.stage === "ok") {
36613
+ innerListeners.forEach((cb) => cb(result.inner));
36614
+ } else if (result.stage === "decrypt-failed") {
36615
+ } else {
36616
+ console.error("[fetchproxy] peer: received a frame that decrypted OK but failed validation:", result.error);
36617
+ if (result.recoveredId !== void 0) {
36618
+ innerListeners.forEach((cb) => cb({
36619
+ type: "response",
36620
+ id: result.recoveredId,
36621
+ ok: false,
36622
+ error: `malformed response failed protocol validation: ${String(result.error)}`
36623
+ }));
36624
+ }
36497
36625
  }
36498
36626
  }
36499
36627
  } catch (e) {
@@ -36770,6 +36898,10 @@ var FetchproxyServer = class {
36770
36898
  pendingIdb = /* @__PURE__ */ new Map();
36771
36899
  // download awaiters resolve the saved-file metadata (path + size + mime).
36772
36900
  pendingDownload = /* @__PURE__ */ new Map();
36901
+ // 1.x+: graphql_query awaiters resolve the GraphQL `data` object. Its
36902
+ // shape is operation-specific, so the awaiter resolves `unknown` and the
36903
+ // caller narrows.
36904
+ pendingGraphql = /* @__PURE__ */ new Map();
36773
36905
  mcpId = null;
36774
36906
  identity = null;
36775
36907
  // 0.5.3+: in-flight role-election / handle-start promise. Set the
@@ -36857,6 +36989,10 @@ var FetchproxyServer = class {
36857
36989
  selector: d.selector,
36858
36990
  ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36859
36991
  })),
36992
+ graphqlOps: (opts.graphqlOps ?? []).map((d) => ({
36993
+ name: d.name,
36994
+ operationName: d.operationName
36995
+ })),
36860
36996
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36861
36997
  // adapter was about to set these to the same numbers anyway; the
36862
36998
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36978,6 +37114,7 @@ var FetchproxyServer = class {
36978
37114
  ownLocalStoragePointers: this.opts.localStoragePointers,
36979
37115
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
36980
37116
  ownDomSelectors: this.opts.domSelectors,
37117
+ ownGraphqlOps: this.opts.graphqlOps,
36981
37118
  onPairCode: this.opts.onPairCode
36982
37119
  });
36983
37120
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -37006,7 +37143,8 @@ var FetchproxyServer = class {
37006
37143
  indexedDbScopes: this.opts.indexedDbScopes,
37007
37144
  localStoragePointers: this.opts.localStoragePointers,
37008
37145
  sessionStoragePointers: this.opts.sessionStoragePointers,
37009
- domSelectors: this.opts.domSelectors
37146
+ domSelectors: this.opts.domSelectors,
37147
+ graphqlOps: this.opts.graphqlOps
37010
37148
  });
37011
37149
  this.peerHandle.onInner((inner) => this.onInner(inner));
37012
37150
  this.peerHandle.onRenegotiate(() => {
@@ -37212,6 +37350,7 @@ var FetchproxyServer = class {
37212
37350
  this.pendingRedirect.delete(id);
37213
37351
  this.pendingDownload.delete(id);
37214
37352
  this.pendingIdb.delete(id);
37353
+ this.pendingGraphql.delete(id);
37215
37354
  }
37216
37355
  throw err;
37217
37356
  }
@@ -38021,6 +38160,52 @@ var FetchproxyServer = class {
38021
38160
  await this.sendInnerFrame(inner);
38022
38161
  return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
38023
38162
  }
38163
+ /**
38164
+ * 1.x+: run a declared GraphQL operation through the page's own Apollo
38165
+ * client (`window.__APOLLO_CLIENT__`) in the signed-in tab's MAIN world.
38166
+ * Requires `'graphql'` in capabilities AND `name` to match a declared
38167
+ * `graphqlOps` entry. The extension resolves `name` → `operationName` →
38168
+ * the live DocumentNode the page already observed, then invokes
38169
+ * `client.query({ query, variables })` — the site's own request path, so
38170
+ * per-request bot telemetry (Akamai etc.) runs automatically.
38171
+ *
38172
+ * Returns the GraphQL `data` object on success (shape is
38173
+ * operation-specific; the caller narrows). Throws a plain `Error` on
38174
+ * developer mistakes (undeclared capability, undeclared name) and a
38175
+ * descriptive `Error` on the `ok:false` bridge path — which includes the
38176
+ * typed "operation not yet observed on this tab" case (open the site's
38177
+ * page and retry).
38178
+ */
38179
+ async graphqlQuery(opts) {
38180
+ if (!this.opts.capabilities.includes("graphql")) {
38181
+ throw new Error('FetchproxyServer.graphqlQuery(): MCP did not declare "graphql" in capabilities');
38182
+ }
38183
+ if (typeof opts.name !== "string" || opts.name.length === 0) {
38184
+ throw new Error("FetchproxyServer.graphqlQuery: opts.name must be a non-empty string");
38185
+ }
38186
+ const declaredNames = this.opts.graphqlOps.map((d) => d.name);
38187
+ if (!declaredNames.includes(opts.name)) {
38188
+ throw new Error(`FetchproxyServer.graphqlQuery: operation ${JSON.stringify(opts.name)} not in declared graphqlOps [${declaredNames.map((n) => JSON.stringify(n)).join(", ")}]`);
38189
+ }
38190
+ await this.ensureConnected();
38191
+ this.throwIfPendingPair();
38192
+ const id = this.nextRequestId++;
38193
+ const inner = {
38194
+ type: "request",
38195
+ id,
38196
+ op: "graphql_query",
38197
+ init: {
38198
+ name: opts.name,
38199
+ variables: opts.variables,
38200
+ ...opts.tabUrl !== void 0 ? { tabUrl: opts.tabUrl } : {}
38201
+ }
38202
+ };
38203
+ const pending = new Promise((resolve, reject) => {
38204
+ this.pendingGraphql.set(id, { resolve, reject });
38205
+ });
38206
+ await this.sendInnerFrame(inner);
38207
+ return this._withVerbTimeout(pending, this.pendingGraphql, id, opts.name);
38208
+ }
38024
38209
  assertScopeSubset(requested, declared, label) {
38025
38210
  const undeclared = undeclaredKeys(requested, declared);
38026
38211
  if (undeclared.length > 0) {
@@ -38158,6 +38343,20 @@ var FetchproxyServer = class {
38158
38343
  }
38159
38344
  return;
38160
38345
  }
38346
+ const graphqlCb = this.pendingGraphql.get(inner.id);
38347
+ if (graphqlCb) {
38348
+ this.pendingGraphql.delete(inner.id);
38349
+ if (inner.ok) {
38350
+ if (inner.op === "graphql_query") {
38351
+ graphqlCb.resolve(inner.data);
38352
+ } else {
38353
+ graphqlCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on graphql_query awaiter`));
38354
+ }
38355
+ } else {
38356
+ graphqlCb.reject(new FetchproxyProtocolError(inner.error));
38357
+ }
38358
+ return;
38359
+ }
38161
38360
  const cookiesCb = this.pendingReadCookies.get(inner.id);
38162
38361
  if (cookiesCb) {
38163
38362
  this.pendingReadCookies.delete(inner.id);
@@ -38209,6 +38408,9 @@ var FetchproxyServer = class {
38209
38408
  for (const { reject } of this.pendingDownload.values())
38210
38409
  reject(err);
38211
38410
  this.pendingDownload.clear();
38411
+ for (const { reject } of this.pendingGraphql.values())
38412
+ reject(err);
38413
+ this.pendingGraphql.clear();
38212
38414
  }
38213
38415
  /**
38214
38416
  * 0.5.2+: read the current pair-pending pair code from whichever handle
@@ -38262,7 +38464,20 @@ var FetchproxyServer = class {
38262
38464
 
38263
38465
  // node_modules/@fetchproxy/bootstrap/dist/index.js
38264
38466
  var defaultFactory = (opts) => new FetchproxyServer(opts);
38265
- 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) {
38266
38481
  const envVar = opts.serverName.toUpperCase().replace(/[^A-Z0-9]/g, "_").replace(/^_+/, "") + "_DISABLE_FETCHPROXY";
38267
38482
  const envVal = process.env[envVar];
38268
38483
  if (envVal !== void 0 && envVal !== "" && envVal !== "0" && envVal !== "false") {
@@ -38389,12 +38604,18 @@ async function bootstrap(opts) {
38389
38604
  });
38390
38605
  indexedDbBucket[`${d.database}/${d.store}`] = values;
38391
38606
  }
38607
+ const absent = (declared, got) => declared.filter((k) => !(k in got));
38392
38608
  return {
38393
38609
  cookies,
38394
38610
  localStorage,
38395
38611
  sessionStorage,
38396
38612
  capturedHeaders,
38397
- 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
+ }
38398
38619
  };
38399
38620
  } finally {
38400
38621
  try {
@@ -38499,7 +38720,7 @@ function loadAccount(env = process.env) {
38499
38720
  // package.json
38500
38721
  var package_default = {
38501
38722
  name: "canvas-parent-mcp",
38502
- version: "1.2.0",
38723
+ version: "1.2.3",
38503
38724
  mcpName: "io.github.chrischall/canvas-parent-mcp",
38504
38725
  description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
38505
38726
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38543,9 +38764,9 @@ var package_default = {
38543
38764
  "test:watch": "vitest"
38544
38765
  },
38545
38766
  dependencies: {
38546
- "@chrischall/mcp-utils": "^0.13.0",
38547
- "@fetchproxy/bootstrap": "^1.0.0",
38548
- "@fetchproxy/server": "^1.0.0",
38767
+ "@chrischall/mcp-utils": "^0.14.0",
38768
+ "@fetchproxy/bootstrap": "^1.9.1",
38769
+ "@fetchproxy/server": "^1.7.0",
38549
38770
  "@modelcontextprotocol/sdk": "^1.29.0",
38550
38771
  dotenv: "^17.4.2",
38551
38772
  zod: "^4.4.3"
@@ -38584,54 +38805,65 @@ async function resolveAuth() {
38584
38805
  const declaredDomain = baseHost.endsWith(".instructure.com") ? "instructure.com" : baseHost;
38585
38806
  const name = readEnv("CANVAS_NAME") ?? baseHost;
38586
38807
  const cleanBaseUrl = baseUrl.replace(/\/$/, "");
38587
- try {
38588
- const session = await bootstrap({
38589
- serverName: package_default.name,
38590
- version: package_default.version,
38591
- domains: [declaredDomain],
38592
- declare: {
38593
- cookies: ["canvas_session", "pseudonym_credentials"],
38594
- localStorage: [],
38595
- sessionStorage: [],
38596
- captureHeaders: []
38597
- }
38598
- });
38599
- const canvasSession = session.cookies["canvas_session"];
38600
- const pseudoCreds = session.cookies["pseudonym_credentials"];
38601
- if (!canvasSession || !pseudoCreds) {
38602
- throw new Error(
38603
- `required cookies not found on ${baseHost}. Sign into your Canvas instance in the browser (with the fetchproxy extension installed) and retry.`
38604
- );
38605
- }
38606
- const cookie = `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
38607
- const account = {
38608
- mode: "session",
38609
- name,
38610
- baseUrl: cleanBaseUrl,
38611
- username: "",
38612
- password: ""
38613
- };
38614
- return {
38615
- account,
38616
- preloaded: { cookie },
38617
- source: "fetchproxy"
38618
- };
38619
- } catch (e) {
38620
- if (classifyBridgeError(e) === "bridge_down") {
38621
- const downErr = e;
38622
- throw new Error(
38623
- `Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
38624
- );
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 liftersByDomain = /* @__PURE__ */ new Map();
38826
+ function lifterFor(declaredDomain) {
38827
+ let lift = liftersByDomain.get(declaredDomain);
38828
+ if (!lift) {
38829
+ lift = createSessionLifter({
38830
+ serverName: package_default.name,
38831
+ version: package_default.version,
38832
+ domains: [declaredDomain],
38833
+ declare: {
38834
+ cookies: ["canvas_session", "pseudonym_credentials"],
38835
+ localStorage: [],
38836
+ sessionStorage: [],
38837
+ captureHeaders: []
38625
38838
  }
38626
- const msg = e instanceof Error ? e.message : String(e);
38839
+ });
38840
+ liftersByDomain.set(declaredDomain, lift);
38841
+ }
38842
+ return lift;
38843
+ }
38844
+ async function liftBrowserCookie(declaredDomain, baseHost) {
38845
+ try {
38846
+ const session = await lifterFor(declaredDomain)();
38847
+ const canvasSession = session.cookies["canvas_session"];
38848
+ const pseudoCreds = session.cookies["pseudonym_credentials"];
38849
+ if (!canvasSession || !pseudoCreds) {
38850
+ throw new Error(
38851
+ `required cookies not found on ${baseHost}. Sign into your Canvas instance in the browser (with the fetchproxy extension installed) and retry.`
38852
+ );
38853
+ }
38854
+ return `canvas_session=${canvasSession}; pseudonym_credentials=${pseudoCreds}`;
38855
+ } catch (e) {
38856
+ if (classifyBridgeError(e) === "bridge_down") {
38857
+ const downErr = e;
38627
38858
  throw new Error(
38628
- `Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, and fetchproxy fallback failed: ${msg}`
38859
+ `Canvas auth: fetchproxy bridge is down (extension service worker unreachable after retry). ${downErr.hint}`
38629
38860
  );
38630
38861
  }
38862
+ const msg = e instanceof Error ? e.message : String(e);
38863
+ throw new Error(
38864
+ `Canvas auth: no CANVAS_TOKEN, CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN, or CANVAS_USERNAME/CANVAS_PASSWORD set, and fetchproxy lift failed: ${msg}`
38865
+ );
38631
38866
  }
38632
- throw new Error(
38633
- "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)."
38634
- );
38635
38867
  }
38636
38868
 
38637
38869
  // src/client.ts
@@ -38869,28 +39101,35 @@ var DEFAULT_MAX_PAGES = 50;
38869
39101
  var CanvasClient = class {
38870
39102
  account;
38871
39103
  sessionLoginFn;
38872
- preloadedCookie;
39104
+ /**
39105
+ * fetchproxy lift. Called on the first login AND on every renewal, which is
39106
+ * what keeps the path alive once the browser cookie lapses.
39107
+ */
39108
+ refreshSession;
38873
39109
  auth;
38874
39110
  /** Lazily-built shared refresh_token exchanger (oauth mode only). */
38875
39111
  oauthRefresh = null;
38876
39112
  /**
38877
- * `preloaded` is the fetchproxy escape hatch: when set, the client uses
38878
- * the supplied cookie header as-if it had just successfully run
38879
- * `sessionLogin()`. On a 401 it falls back to the lazy login flow only if
38880
- * usable credentials are present on the account — otherwise the 401
38881
- * surfaces as a TokenExpiredError (re-sign-in happens in the browser, not
38882
- * by re-running a form login with empty creds).
39113
+ * `refreshSession` is the fetchproxy escape hatch: when set, it REPLACES
39114
+ * `sessionLogin()` as the way this client mints a session cookie. It is
39115
+ * called lazily on the first request and again on every 401, so a lapsed
39116
+ * browser cookie recovers by re-reading the tab instead of dead-ending.
39117
+ *
39118
+ * It must re-read the browser each time rather than return a captured
39119
+ * value — that capture-once shape is precisely what made a 401 terminal
39120
+ * here, with a restart the only cure.
38883
39121
  */
38884
39122
  constructor(account, opts = {}) {
38885
39123
  this.account = account;
38886
39124
  this.sessionLoginFn = opts.sessionLogin ?? sessionLogin;
38887
- this.preloadedCookie = opts.preloaded?.cookie ?? null;
39125
+ this.refreshSession = opts.refreshSession ?? null;
38888
39126
  this.auth = new CookieSessionManager({
38889
39127
  login: () => this.login(),
38890
- // Reactive expiry: only a 401 in a mode that can re-mint warrants a replay.
38891
- // token mode and fetchproxy-session (empty creds) can't re-auth here — their
38892
- // 401 falls through as a Response and `mapStatus` turns it into a
38893
- // TokenExpiredError so the user is told to re-sign-in / re-config.
39128
+ // Reactive expiry: only a 401 in a mode that can re-mint warrants a
39129
+ // replay. token mode still can't (no refresh path), and a session
39130
+ // account with neither a lift nor credentials still can't — those 401s
39131
+ // fall through as a Response and `mapStatus` turns them into a
39132
+ // TokenExpiredError.
38894
39133
  isExpired: (res) => res.status === 401 && this.canReauth()
38895
39134
  });
38896
39135
  }
@@ -38998,7 +39237,7 @@ var CanvasClient = class {
38998
39237
  canReauth() {
38999
39238
  const acct = this.account;
39000
39239
  if (acct.mode === "oauth") return true;
39001
- if (acct.mode === "session") return !!acct.username && !!acct.password;
39240
+ if (acct.mode === "session") return !!this.refreshSession || !!acct.username && !!acct.password;
39002
39241
  return false;
39003
39242
  }
39004
39243
  /** Mint the auth credential for the current mode. Invoked by the manager. */
@@ -39008,8 +39247,8 @@ var CanvasClient = class {
39008
39247
  return { headers: { Authorization: `Bearer ${acct.token}` } };
39009
39248
  }
39010
39249
  if (acct.mode === "session") {
39011
- if (this.preloadedCookie !== null) {
39012
- return { headers: { Cookie: this.preloadedCookie } };
39250
+ if (this.refreshSession !== null) {
39251
+ return { headers: { Cookie: await this.refreshSession() } };
39013
39252
  }
39014
39253
  return { headers: { Cookie: await this.mintSessionCookie(acct) } };
39015
39254
  }
@@ -39475,13 +39714,13 @@ try {
39475
39714
  }
39476
39715
  await runMcp({
39477
39716
  name: "canvas",
39478
- version: "1.2.0",
39717
+ version: "1.2.3",
39479
39718
  // x-release-please-version
39480
39719
  tools: [
39481
39720
  (server) => {
39482
39721
  if (resolved) {
39483
39722
  const client = new CanvasClient(resolved.account, {
39484
- preloaded: resolved.preloaded
39723
+ refreshSession: resolved.refresh
39485
39724
  });
39486
39725
  registerProfileTools(server, client);
39487
39726
  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.0', // x-release-please-version
49
+ version: '1.2.3', // 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.0",
3
+ "version": "1.2.3",
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,9 +44,9 @@
44
44
  "test:watch": "vitest"
45
45
  },
46
46
  "dependencies": {
47
- "@chrischall/mcp-utils": "^0.13.0",
48
- "@fetchproxy/bootstrap": "^1.0.0",
49
- "@fetchproxy/server": "^1.0.0",
47
+ "@chrischall/mcp-utils": "^0.14.0",
48
+ "@fetchproxy/bootstrap": "^1.9.1",
49
+ "@fetchproxy/server": "^1.7.0",
50
50
  "@modelcontextprotocol/sdk": "^1.29.0",
51
51
  "dotenv": "^17.4.2",
52
52
  "zod": "^4.4.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.2.0",
9
+ "version": "1.2.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "1.2.0",
14
+ "version": "1.2.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },