canvas-parent-mcp 0.1.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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": "0.1.3"
10
+ "version": "1.0.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Canvas LMS",
16
16
  "source": "./",
17
17
  "description": "Canvas LMS (Instructure) MCP server for Claude — student/observer access via natural language",
18
- "version": "0.1.3",
18
+ "version": "1.0.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
3
  "displayName": "Canvas LMS",
4
- "version": "0.1.3",
4
+ "version": "1.0.0",
5
5
  "description": "Canvas LMS (Instructure) MCP server for Claude — courses, grades, assignments, announcements, planner, conversations, files",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/.mcp.json CHANGED
@@ -6,6 +6,8 @@
6
6
  "env": {
7
7
  "CANVAS_BASE_URL": "${CANVAS_BASE_URL}",
8
8
  "CANVAS_TOKEN": "${CANVAS_TOKEN}",
9
+ "CANVAS_USERNAME": "${CANVAS_USERNAME}",
10
+ "CANVAS_PASSWORD": "${CANVAS_PASSWORD}",
9
11
  "CANVAS_CLIENT_ID": "${CANVAS_CLIENT_ID}",
10
12
  "CANVAS_CLIENT_SECRET": "${CANVAS_CLIENT_SECRET}",
11
13
  "CANVAS_REFRESH_TOKEN": "${CANVAS_REFRESH_TOKEN}",
package/README.md CHANGED
@@ -27,34 +27,36 @@ Tools that the harness will gate as write/IO operations: `canvas_download_file`.
27
27
 
28
28
  ## Configuration
29
29
 
30
- Set the base URL plus *one* auth mode:
30
+ Set the base URL plus *one* auth mode. **Username/password is recommended** — most schools have disabled personal-access-token creation, and this mode auto-logs-in on first request and silently re-mints session cookies on 401, so you never have to re-bootstrap.
31
31
 
32
- **Personal access token (recommended):**
32
+ **Username/password (recommended):**
33
33
 
34
34
  ```
35
35
  CANVAS_BASE_URL=https://cms.instructure.com
36
- CANVAS_TOKEN=your-personal-access-token
36
+ CANVAS_USERNAME=me@example.com
37
+ CANVAS_PASSWORD=your-canvas-password
37
38
  CANVAS_NAME=cms # optional, defaults to host portion of base URL
38
39
  ```
39
40
 
40
- **Username/password (auto-login + auto-renew, simplest when tokens are disabled):**
41
+ **Direct Canvas accounts only** — won't work with SAML/Google/Microsoft SSO or 2FA. Treat `.env` like a password file: do not commit it.
41
42
 
42
- ```
43
- CANVAS_BASE_URL=https://cms.instructure.com
44
- CANVAS_USERNAME=me@example.com
45
- CANVAS_PASSWORD=your-canvas-password
46
- ```
43
+ ### Advanced alternatives
47
44
 
48
- The MCP logs in lazily on the first request and silently re-mints cookies on 401, so you never have to re-bootstrap. **Direct Canvas accounts only** — won't work with SAML/Google/Microsoft SSO or 2FA. Treat `.env` like a password file: do not commit it.
45
+ If your admin still allows tokens, or your account uses SSO (so username/password won't work), pick one of these instead:
49
46
 
50
- **Session cookie (precomputed, no auto-renewal):**
47
+ <details>
48
+ <summary><b>Personal access token</b> — simplest if your admin allows it</summary>
51
49
 
52
50
  ```
53
51
  CANVAS_BASE_URL=https://cms.instructure.com
54
- CANVAS_COOKIE=canvas_session=...; pseudonym_credentials=...; ...
52
+ CANVAS_TOKEN=your-personal-access-token
55
53
  ```
56
54
 
57
- **OAuth (advanced):**
55
+ Generate via Canvas → Account → Settings → "+ New Access Token". Most institutions have disabled this for non-admins.
56
+ </details>
57
+
58
+ <details>
59
+ <summary><b>OAuth via mobile QR code</b> — bootstrapped from the mobile-app login flow</summary>
58
60
 
59
61
  ```
60
62
  CANVAS_BASE_URL=https://cms.instructure.com
@@ -63,30 +65,16 @@ CANVAS_CLIENT_SECRET=...
63
65
  CANVAS_REFRESH_TOKEN=...
64
66
  ```
65
67
 
66
- Precedence when multiple are set: `CANVAS_TOKEN` > session env vars (`CANVAS_USERNAME`+`CANVAS_PASSWORD` and/or `CANVAS_COOKIE`) > OAuth. If both `CANVAS_COOKIE` and username/password are set, the cookie is used initially and username/password is the fallback for re-minting on 401.
68
+ If your account uses SSO (so username/password can't auth), mint OAuth credentials by reusing the Canvas mobile-app QR-login flow — see [Bootstrapping OAuth via the mobile QR code](#bootstrapping-oauth-via-the-mobile-qr-code) below.
69
+ </details>
67
70
 
68
- See `.env.example`.
71
+ **Precedence when multiple are set:** `CANVAS_TOKEN` > `CANVAS_USERNAME`+`CANVAS_PASSWORD` > OAuth.
69
72
 
70
- ### Bootstrapping cookie auth via username/password
71
-
72
- When your Canvas admin has disabled personal-access-token creation entirely, the bundled `canvas-parent-mcp-login` CLI logs in via the same `/login/canvas` form your browser uses and prints a `CANVAS_COOKIE` env var:
73
-
74
- ```
75
- canvas-parent-mcp-login -b https://cms.instructure.com -u me@example.com >> .env
76
- # Password: ****** (TTY prompt with no echo)
77
- ```
78
-
79
- Or piped (no TTY prompt):
80
-
81
- ```
82
- canvas-parent-mcp-login -b https://cms.instructure.com -u me@example.com <<< "$PW" >> .env
83
- ```
84
-
85
- The "remember me" cookie (`pseudonym_credentials`) is good for ~2 weeks; re-run the CLI when API calls start returning 401s. **Limitations**: only works for direct Canvas accounts — institutions using SAML/Google/Microsoft SSO or 2FA cannot use this flow, and the CLI exits with a clear error if it detects an SSO redirect or a missing `pseudonym_credentials` cookie. Treat the resulting `CANVAS_COOKIE` value like a password.
73
+ See `.env.example`.
86
74
 
87
75
  ### Bootstrapping OAuth via the mobile QR code
88
76
 
89
- If your Canvas admin has disabled personal-access-token creation (some institutions restrict tokens to "the mobile app only"), you can mint OAuth credentials by going through the same QR-login flow that the official Canvas mobile apps use:
77
+ If your Canvas admin has disabled personal-access-token creation (some institutions restrict tokens to "the mobile app only") AND your account uses SSO so username/password can't auth, you can mint OAuth credentials by going through the same QR-login flow that the official Canvas mobile apps use:
90
78
 
91
79
  1. In Canvas web, open **Account → QR for Mobile Login** — Canvas shows a QR that's valid for 10 minutes.
92
80
  2. Decode the QR with any QR reader. The result is a URL on `sso.canvaslms.com` like `https://sso.canvaslms.com/canvas/login?domain=...&code=...`.
package/dist/bundle.js CHANGED
@@ -30864,7 +30864,7 @@ function loadAccount(env = process.env) {
30864
30864
  const baseUrl = env.CANVAS_BASE_URL;
30865
30865
  if (!baseUrl) {
30866
30866
  throw new Error(
30867
- "Missing required env var: CANVAS_BASE_URL. Set CANVAS_BASE_URL (e.g. https://cms.instructure.com) plus either CANVAS_TOKEN or the OAuth triple."
30867
+ "Missing required env var: CANVAS_BASE_URL. Set CANVAS_BASE_URL (e.g. https://cms.instructure.com) plus an auth mode."
30868
30868
  );
30869
30869
  }
30870
30870
  if (!/^https:\/\//.test(baseUrl)) {
@@ -30873,7 +30873,6 @@ function loadAccount(env = process.env) {
30873
30873
  const cleanBaseUrl = baseUrl.replace(/\/$/, "");
30874
30874
  const name = env.CANVAS_NAME || new URL(cleanBaseUrl).host;
30875
30875
  const token = env.CANVAS_TOKEN;
30876
- const cookie = env.CANVAS_COOKIE;
30877
30876
  const username = env.CANVAS_USERNAME;
30878
30877
  const password = env.CANVAS_PASSWORD;
30879
30878
  const clientId = env.CANVAS_CLIENT_ID;
@@ -30884,12 +30883,18 @@ function loadAccount(env = process.env) {
30884
30883
  const hasPartialOAuth = oauthSet.length > 0 && oauthSet.length < 3;
30885
30884
  const hasFullUserPass = !!username && !!password;
30886
30885
  const hasPartialUserPass = !!username !== !!password;
30887
- const hasSession = !!cookie || hasFullUserPass;
30888
30886
  if (token) {
30889
- const others = [hasFullOAuth ? "OAuth" : null, hasSession ? "session env vars" : null].filter(Boolean).join(" and ");
30890
- if (others) {
30887
+ const others = [];
30888
+ if (hasFullOAuth) others.push("OAuth env vars");
30889
+ else if (hasPartialOAuth) others.push("partial OAuth env vars (CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN)");
30890
+ if (hasFullUserPass) others.push("username/password env vars");
30891
+ else if (hasPartialUserPass) {
30892
+ others.push(`partial username/password (only ${username ? "CANVAS_USERNAME" : "CANVAS_PASSWORD"} set)`);
30893
+ }
30894
+ if (others.length > 0) {
30895
+ const joined = others.join(" and ");
30891
30896
  console.error(
30892
- `[canvas-mcp] CANVAS_TOKEN takes precedence over ${others} \u2014 using CANVAS_TOKEN. Unset CANVAS_TOKEN to use ${others}.`
30897
+ `[canvas-parent-mcp] CANVAS_TOKEN takes precedence over ${joined} \u2014 using CANVAS_TOKEN. Unset CANVAS_TOKEN to use ${joined}.`
30893
30898
  );
30894
30899
  }
30895
30900
  return { mode: "token", name, baseUrl: cleanBaseUrl, token };
@@ -30900,19 +30905,13 @@ function loadAccount(env = process.env) {
30900
30905
  `Incomplete username/password config \u2014 missing: ${missing}. Set both CANVAS_USERNAME and CANVAS_PASSWORD, or unset both.`
30901
30906
  );
30902
30907
  }
30903
- if (hasSession) {
30908
+ if (hasFullUserPass) {
30904
30909
  if (hasFullOAuth) {
30905
30910
  console.error(
30906
- "[canvas-mcp] Session env vars (CANVAS_COOKIE / CANVAS_USERNAME+CANVAS_PASSWORD) take precedence over OAuth \u2014 using session mode. Unset them to use OAuth."
30911
+ "[canvas-parent-mcp] CANVAS_USERNAME+CANVAS_PASSWORD takes precedence over OAuth \u2014 using username/password. Unset them to use OAuth."
30907
30912
  );
30908
30913
  }
30909
- const acct = { mode: "session", name, baseUrl: cleanBaseUrl };
30910
- if (cookie) acct.cookie = cookie;
30911
- if (hasFullUserPass) {
30912
- acct.username = username;
30913
- acct.password = password;
30914
- }
30915
- return acct;
30914
+ return { mode: "session", name, baseUrl: cleanBaseUrl, username, password };
30916
30915
  }
30917
30916
  if (hasPartialOAuth) {
30918
30917
  const missing = [];
@@ -30920,7 +30919,7 @@ function loadAccount(env = process.env) {
30920
30919
  if (!clientSecret) missing.push("CANVAS_CLIENT_SECRET");
30921
30920
  if (!refreshToken) missing.push("CANVAS_REFRESH_TOKEN");
30922
30921
  throw new Error(
30923
- `Incomplete OAuth config \u2014 missing: ${missing.join(", ")}. Set all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN (or set CANVAS_TOKEN / CANVAS_COOKIE / CANVAS_USERNAME+CANVAS_PASSWORD instead).`
30922
+ `Incomplete OAuth config \u2014 missing: ${missing.join(", ")}. Set all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN (or set CANVAS_TOKEN / CANVAS_USERNAME+CANVAS_PASSWORD instead).`
30924
30923
  );
30925
30924
  }
30926
30925
  if (hasFullOAuth) {
@@ -30935,7 +30934,7 @@ function loadAccount(env = process.env) {
30935
30934
  };
30936
30935
  }
30937
30936
  throw new Error(
30938
- "Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), CANVAS_COOKIE (session cookie jar from canvas-parent-mcp-login), or all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN."
30937
+ "Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), or all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN."
30939
30938
  );
30940
30939
  }
30941
30940
 
@@ -31041,10 +31040,7 @@ async function sessionLogin(opts) {
31041
31040
  const sessionCookies = respCookies.filter(
31042
31041
  (c) => ["canvas_session", "pseudonym_credentials", "_csrf_token", "log_session_id"].includes(c.name)
31043
31042
  );
31044
- return {
31045
- baseUrl,
31046
- cookie: serializeCookies(sessionCookies)
31047
- };
31043
+ return { cookie: serializeCookies(sessionCookies) };
31048
31044
  }
31049
31045
 
31050
31046
  // src/client.ts
@@ -31054,6 +31050,7 @@ var CanvasClient = class {
31054
31050
  account;
31055
31051
  refreshInFlight = null;
31056
31052
  accessTokenExpiresAt = 0;
31053
+ sessionCookie = null;
31057
31054
  sessionLoginFn;
31058
31055
  constructor(account2, opts = {}) {
31059
31056
  this.account = account2;
@@ -31064,7 +31061,7 @@ var CanvasClient = class {
31064
31061
  return { name: this.account.name, baseUrl: this.account.baseUrl, mode: this.account.mode };
31065
31062
  }
31066
31063
  async request(path, opts = {}) {
31067
- const res = await this.doRawRequest(path, opts, false);
31064
+ const res = await this.doRawRequest(path, opts);
31068
31065
  const text = await res.text();
31069
31066
  if (opts.responseType === "text") return text;
31070
31067
  return parseJsonBody(text) ?? null;
@@ -31075,7 +31072,7 @@ var CanvasClient = class {
31075
31072
  let url2 = injectPerPage(path, perPage);
31076
31073
  const out = [];
31077
31074
  for (let page = 0; page < maxPages; page++) {
31078
- const res = await this.doRawRequest(url2, opts, false);
31075
+ const res = await this.doRawRequest(url2, opts);
31079
31076
  const text = await res.text();
31080
31077
  const parsed = parseJsonBody(text) ?? [];
31081
31078
  for (const item of parsed) out.push(item);
@@ -31100,11 +31097,8 @@ var CanvasClient = class {
31100
31097
  } catch {
31101
31098
  throw new ParentDirectoryMissingError(parent);
31102
31099
  }
31103
- await this.ensureAuth();
31104
31100
  const url2 = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
31105
- const res = await fetch(url2, {
31106
- headers: this.getAuthHeaders()
31107
- });
31101
+ const res = await this.authedFetch(url2, {});
31108
31102
  if (res.status === 404) throw new Error(`Canvas download 404 for ${path}`);
31109
31103
  if (!res.ok) throw new Error(`Canvas download ${res.status} for ${path}`);
31110
31104
  const buf = new Uint8Array(await res.arrayBuffer());
@@ -31115,42 +31109,43 @@ var CanvasClient = class {
31115
31109
  contentType: res.headers.get("content-type") ?? "application/octet-stream"
31116
31110
  };
31117
31111
  }
31118
- async doRawRequest(path, opts, isRetry) {
31119
- await this.ensureAuth();
31112
+ async doRawRequest(path, opts) {
31120
31113
  const url2 = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
31121
31114
  const accept = opts.responseType === "text" ? "text/html, text/plain, */*" : "application/json+canvas-string-ids, application/json";
31122
- const res = await fetch(url2, {
31115
+ const res = await this.authedFetch(url2, {
31123
31116
  method: opts.method ?? "GET",
31124
- headers: {
31125
- ...this.getAuthHeaders(),
31126
- Accept: accept,
31127
- ...opts.headers ?? {}
31128
- },
31117
+ headers: { Accept: accept, ...opts.headers ?? {} },
31129
31118
  body: opts.body
31130
31119
  });
31120
+ if (res.status === 404) throw new Error(`Canvas 404 ${path}`);
31121
+ if (res.status >= 500) throw new CanvasUnreachableError(res.status);
31122
+ if (!res.ok) throw new Error(`Canvas ${res.status} ${res.statusText} for ${path}`);
31123
+ return res;
31124
+ }
31125
+ /**
31126
+ * Fetch with auth headers attached and transparent 401 retry. Token mode has
31127
+ * no refresh path so a 401 throws immediately; oauth and session both attempt
31128
+ * one re-auth before giving up. Used by both API requests and file downloads.
31129
+ */
31130
+ async authedFetch(url2, init, isRetry = false) {
31131
+ await this.ensureAuth();
31132
+ const res = await fetch(url2, {
31133
+ ...init,
31134
+ headers: { ...this.getAuthHeaders(), ...init.headers }
31135
+ });
31131
31136
  if (res.status === 401) {
31132
- if (isRetry || !this.canRefreshAuth()) {
31137
+ if (isRetry || this.account.mode === "token") {
31133
31138
  throw new TokenExpiredError(this.account.mode);
31134
31139
  }
31135
31140
  await this.ensureAuth({ force: true });
31136
- return this.doRawRequest(path, opts, true);
31141
+ return this.authedFetch(url2, init, true);
31137
31142
  }
31138
- if (res.status === 404) throw new Error(`Canvas 404 ${path}`);
31139
- if (res.status >= 500) throw new CanvasUnreachableError(res.status);
31140
- if (!res.ok) throw new Error(`Canvas ${res.status} ${res.statusText} for ${path}`);
31141
31143
  return res;
31142
31144
  }
31143
- /** Whether this account can mint or refresh credentials in response to a 401. */
31144
- canRefreshAuth() {
31145
- const acct = this.account;
31146
- if (acct.mode === "oauth") return true;
31147
- if (acct.mode === "session") return !!acct.username && !!acct.password;
31148
- return false;
31149
- }
31150
31145
  getAuthHeaders() {
31151
31146
  const acct = this.account;
31152
31147
  if (acct.mode === "token") return { Authorization: `Bearer ${acct.token}` };
31153
- if (acct.mode === "session") return { Cookie: acct.cookie };
31148
+ if (acct.mode === "session") return { Cookie: this.sessionCookie };
31154
31149
  return { Authorization: `Bearer ${acct.accessToken}` };
31155
31150
  }
31156
31151
  async ensureAuth(opts = {}) {
@@ -31158,10 +31153,7 @@ var CanvasClient = class {
31158
31153
  if (acct.mode === "token") return;
31159
31154
  if (!opts.force) {
31160
31155
  if (acct.mode === "oauth" && acct.accessToken && Date.now() < this.accessTokenExpiresAt) return;
31161
- if (acct.mode === "session" && acct.cookie) return;
31162
- }
31163
- if (acct.mode === "session" && !this.canRefreshAuth()) {
31164
- throw new TokenExpiredError("session");
31156
+ if (acct.mode === "session" && this.sessionCookie) return;
31165
31157
  }
31166
31158
  if (this.refreshInFlight) {
31167
31159
  await this.refreshInFlight;
@@ -31180,7 +31172,7 @@ var CanvasClient = class {
31180
31172
  username: acct.username,
31181
31173
  password: acct.password
31182
31174
  });
31183
- acct.cookie = result.cookie;
31175
+ this.sessionCookie = result.cookie;
31184
31176
  }
31185
31177
  async refreshAccessToken(acct) {
31186
31178
  const body = new URLSearchParams({
@@ -31224,7 +31216,7 @@ function parseLinkHeader(header) {
31224
31216
  }
31225
31217
  var TokenExpiredError = class extends Error {
31226
31218
  constructor(mode, detail) {
31227
- const base = mode === "token" ? "Canvas access token rejected (401). Check CANVAS_TOKEN \u2014 it may be expired or revoked." : mode === "session" ? "Canvas session cookie rejected (401). Re-run canvas-parent-mcp-login to mint a fresh CANVAS_COOKIE." : "Canvas OAuth refresh failed. Check CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN.";
31219
+ const base = mode === "token" ? "Canvas access token rejected (401). Check CANVAS_TOKEN \u2014 it may be expired or revoked." : mode === "session" ? "Canvas session login failed (401). Check CANVAS_USERNAME / CANVAS_PASSWORD \u2014 they may have changed, or the account may be locked or behind SSO." : "Canvas OAuth refresh failed. Check CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN.";
31228
31220
  super(detail ? `${base} (${detail})` : base);
31229
31221
  this.mode = mode;
31230
31222
  this.detail = detail;
@@ -31642,7 +31634,7 @@ try {
31642
31634
  }
31643
31635
  var account = loadAccount();
31644
31636
  var client = new CanvasClient(account);
31645
- var server = new McpServer({ name: "canvas", version: "0.1.3" });
31637
+ var server = new McpServer({ name: "canvas", version: "1.0.0" });
31646
31638
  registerProfileTools(server, client);
31647
31639
  registerObserveeTools(server, client);
31648
31640
  registerCourseTools(server, client);
package/dist/client.js CHANGED
@@ -7,6 +7,7 @@ export class CanvasClient {
7
7
  account;
8
8
  refreshInFlight = null;
9
9
  accessTokenExpiresAt = 0;
10
+ sessionCookie = null;
10
11
  sessionLoginFn;
11
12
  constructor(account, opts = {}) {
12
13
  this.account = account;
@@ -17,7 +18,7 @@ export class CanvasClient {
17
18
  return { name: this.account.name, baseUrl: this.account.baseUrl, mode: this.account.mode };
18
19
  }
19
20
  async request(path, opts = {}) {
20
- const res = await this.doRawRequest(path, opts, false);
21
+ const res = await this.doRawRequest(path, opts);
21
22
  const text = await res.text();
22
23
  if (opts.responseType === 'text')
23
24
  return text;
@@ -29,7 +30,7 @@ export class CanvasClient {
29
30
  let url = injectPerPage(path, perPage);
30
31
  const out = [];
31
32
  for (let page = 0; page < maxPages; page++) {
32
- const res = await this.doRawRequest(url, opts, false);
33
+ const res = await this.doRawRequest(url, opts);
33
34
  const text = await res.text();
34
35
  const parsed = parseJsonBody(text) ?? [];
35
36
  for (const item of parsed)
@@ -59,11 +60,8 @@ export class CanvasClient {
59
60
  catch {
60
61
  throw new ParentDirectoryMissingError(parent);
61
62
  }
62
- await this.ensureAuth();
63
63
  const url = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
64
- const res = await fetch(url, {
65
- headers: this.getAuthHeaders(),
66
- });
64
+ const res = await this.authedFetch(url, {});
67
65
  if (res.status === 404)
68
66
  throw new Error(`Canvas download 404 for ${path}`);
69
67
  if (!res.ok)
@@ -76,28 +74,16 @@ export class CanvasClient {
76
74
  contentType: res.headers.get('content-type') ?? 'application/octet-stream',
77
75
  };
78
76
  }
79
- async doRawRequest(path, opts, isRetry) {
80
- await this.ensureAuth();
77
+ async doRawRequest(path, opts) {
81
78
  const url = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
82
79
  const accept = opts.responseType === 'text'
83
80
  ? 'text/html, text/plain, */*'
84
81
  : 'application/json+canvas-string-ids, application/json';
85
- const res = await fetch(url, {
82
+ const res = await this.authedFetch(url, {
86
83
  method: opts.method ?? 'GET',
87
- headers: {
88
- ...this.getAuthHeaders(),
89
- Accept: accept,
90
- ...(opts.headers ?? {}),
91
- },
84
+ headers: { Accept: accept, ...(opts.headers ?? {}) },
92
85
  body: opts.body,
93
86
  });
94
- if (res.status === 401) {
95
- if (isRetry || !this.canRefreshAuth()) {
96
- throw new TokenExpiredError(this.account.mode);
97
- }
98
- await this.ensureAuth({ force: true });
99
- return this.doRawRequest(path, opts, true);
100
- }
101
87
  if (res.status === 404)
102
88
  throw new Error(`Canvas 404 ${path}`);
103
89
  if (res.status >= 500)
@@ -106,39 +92,45 @@ export class CanvasClient {
106
92
  throw new Error(`Canvas ${res.status} ${res.statusText} for ${path}`);
107
93
  return res;
108
94
  }
109
- /** Whether this account can mint or refresh credentials in response to a 401. */
110
- canRefreshAuth() {
111
- const acct = this.account;
112
- if (acct.mode === 'oauth')
113
- return true;
114
- if (acct.mode === 'session')
115
- return !!acct.username && !!acct.password;
116
- return false;
95
+ /**
96
+ * Fetch with auth headers attached and transparent 401 retry. Token mode has
97
+ * no refresh path so a 401 throws immediately; oauth and session both attempt
98
+ * one re-auth before giving up. Used by both API requests and file downloads.
99
+ */
100
+ async authedFetch(url, init, isRetry = false) {
101
+ await this.ensureAuth();
102
+ const res = await fetch(url, {
103
+ ...init,
104
+ headers: { ...this.getAuthHeaders(), ...init.headers },
105
+ });
106
+ if (res.status === 401) {
107
+ if (isRetry || this.account.mode === 'token') {
108
+ throw new TokenExpiredError(this.account.mode);
109
+ }
110
+ await this.ensureAuth({ force: true });
111
+ return this.authedFetch(url, init, true);
112
+ }
113
+ return res;
117
114
  }
118
115
  getAuthHeaders() {
119
116
  const acct = this.account;
120
- // For oauth/session: callers always invoke ensureAuth() first, which guarantees the credential is set.
121
117
  if (acct.mode === 'token')
122
118
  return { Authorization: `Bearer ${acct.token}` };
119
+ // For oauth/session: callers always invoke ensureAuth() first, which guarantees the credential is set.
123
120
  if (acct.mode === 'session')
124
- return { Cookie: acct.cookie };
121
+ return { Cookie: this.sessionCookie };
125
122
  return { Authorization: `Bearer ${acct.accessToken}` };
126
123
  }
127
124
  async ensureAuth(opts = {}) {
128
125
  const acct = this.account;
129
126
  if (acct.mode === 'token')
130
127
  return;
131
- // Decide whether the cached creds are still good.
132
128
  if (!opts.force) {
133
129
  if (acct.mode === 'oauth' && acct.accessToken && Date.now() < this.accessTokenExpiresAt)
134
130
  return;
135
- if (acct.mode === 'session' && acct.cookie)
131
+ if (acct.mode === 'session' && this.sessionCookie)
136
132
  return;
137
133
  }
138
- // Session mode without u/p has nothing to refresh — caller must rotate CANVAS_COOKIE.
139
- if (acct.mode === 'session' && !this.canRefreshAuth()) {
140
- throw new TokenExpiredError('session');
141
- }
142
134
  if (this.refreshInFlight) {
143
135
  await this.refreshInFlight;
144
136
  return;
@@ -159,7 +151,7 @@ export class CanvasClient {
159
151
  username: acct.username,
160
152
  password: acct.password,
161
153
  });
162
- acct.cookie = result.cookie;
154
+ this.sessionCookie = result.cookie;
163
155
  }
164
156
  async refreshAccessToken(acct) {
165
157
  const body = new URLSearchParams({
@@ -217,7 +209,7 @@ export class TokenExpiredError extends Error {
217
209
  const base = mode === 'token'
218
210
  ? 'Canvas access token rejected (401). Check CANVAS_TOKEN — it may be expired or revoked.'
219
211
  : mode === 'session'
220
- ? 'Canvas session cookie rejected (401). Re-run canvas-parent-mcp-login to mint a fresh CANVAS_COOKIE.'
212
+ ? 'Canvas session login failed (401). Check CANVAS_USERNAME / CANVAS_PASSWORD — they may have changed, or the account may be locked or behind SSO.'
221
213
  : 'Canvas OAuth refresh failed. Check CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN.';
222
214
  super(detail ? `${base} (${detail})` : base);
223
215
  this.mode = mode;
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ export function loadAccount(env = process.env) {
2
2
  const baseUrl = env.CANVAS_BASE_URL;
3
3
  if (!baseUrl) {
4
4
  throw new Error('Missing required env var: CANVAS_BASE_URL. ' +
5
- 'Set CANVAS_BASE_URL (e.g. https://cms.instructure.com) plus either CANVAS_TOKEN or the OAuth triple.');
5
+ 'Set CANVAS_BASE_URL (e.g. https://cms.instructure.com) plus an auth mode.');
6
6
  }
7
7
  if (!/^https:\/\//.test(baseUrl)) {
8
8
  throw new Error(`CANVAS_BASE_URL must be an https URL, got: '${baseUrl}'`);
@@ -10,7 +10,6 @@ export function loadAccount(env = process.env) {
10
10
  const cleanBaseUrl = baseUrl.replace(/\/$/, '');
11
11
  const name = env.CANVAS_NAME || new URL(cleanBaseUrl).host;
12
12
  const token = env.CANVAS_TOKEN;
13
- const cookie = env.CANVAS_COOKIE;
14
13
  const username = env.CANVAS_USERNAME;
15
14
  const password = env.CANVAS_PASSWORD;
16
15
  const clientId = env.CANVAS_CLIENT_ID;
@@ -21,14 +20,21 @@ export function loadAccount(env = process.env) {
21
20
  const hasPartialOAuth = oauthSet.length > 0 && oauthSet.length < 3;
22
21
  const hasFullUserPass = !!username && !!password;
23
22
  const hasPartialUserPass = (!!username) !== (!!password);
24
- const hasSession = !!cookie || hasFullUserPass;
25
23
  if (token) {
26
- const others = [hasFullOAuth ? 'OAuth' : null, hasSession ? 'session env vars' : null]
27
- .filter(Boolean)
28
- .join(' and ');
29
- if (others) {
30
- console.error(`[canvas-mcp] CANVAS_TOKEN takes precedence over ${others} — using CANVAS_TOKEN. ` +
31
- `Unset CANVAS_TOKEN to use ${others}.`);
24
+ const others = [];
25
+ if (hasFullOAuth)
26
+ others.push('OAuth env vars');
27
+ else if (hasPartialOAuth)
28
+ others.push('partial OAuth env vars (CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN)');
29
+ if (hasFullUserPass)
30
+ others.push('username/password env vars');
31
+ else if (hasPartialUserPass) {
32
+ others.push(`partial username/password (only ${username ? 'CANVAS_USERNAME' : 'CANVAS_PASSWORD'} set)`);
33
+ }
34
+ if (others.length > 0) {
35
+ const joined = others.join(' and ');
36
+ console.error(`[canvas-parent-mcp] CANVAS_TOKEN takes precedence over ${joined} — using CANVAS_TOKEN. ` +
37
+ `Unset CANVAS_TOKEN to use ${joined}.`);
32
38
  }
33
39
  return { mode: 'token', name, baseUrl: cleanBaseUrl, token };
34
40
  }
@@ -37,19 +43,12 @@ export function loadAccount(env = process.env) {
37
43
  throw new Error(`Incomplete username/password config — missing: ${missing}. ` +
38
44
  'Set both CANVAS_USERNAME and CANVAS_PASSWORD, or unset both.');
39
45
  }
40
- if (hasSession) {
46
+ if (hasFullUserPass) {
41
47
  if (hasFullOAuth) {
42
- console.error('[canvas-mcp] Session env vars (CANVAS_COOKIE / CANVAS_USERNAME+CANVAS_PASSWORD) take precedence ' +
43
- 'over OAuth — using session mode. Unset them to use OAuth.');
44
- }
45
- const acct = { mode: 'session', name, baseUrl: cleanBaseUrl };
46
- if (cookie)
47
- acct.cookie = cookie;
48
- if (hasFullUserPass) {
49
- acct.username = username;
50
- acct.password = password;
48
+ console.error('[canvas-parent-mcp] CANVAS_USERNAME+CANVAS_PASSWORD takes precedence over OAuth — using ' +
49
+ 'username/password. Unset them to use OAuth.');
51
50
  }
52
- return acct;
51
+ return { mode: 'session', name, baseUrl: cleanBaseUrl, username: username, password: password };
53
52
  }
54
53
  if (hasPartialOAuth) {
55
54
  const missing = [];
@@ -61,7 +60,7 @@ export function loadAccount(env = process.env) {
61
60
  missing.push('CANVAS_REFRESH_TOKEN');
62
61
  throw new Error(`Incomplete OAuth config — missing: ${missing.join(', ')}. ` +
63
62
  'Set all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN ' +
64
- '(or set CANVAS_TOKEN / CANVAS_COOKIE / CANVAS_USERNAME+CANVAS_PASSWORD instead).');
63
+ '(or set CANVAS_TOKEN / CANVAS_USERNAME+CANVAS_PASSWORD instead).');
65
64
  }
66
65
  if (hasFullOAuth) {
67
66
  return {
@@ -76,6 +75,5 @@ export function loadAccount(env = process.env) {
76
75
  }
77
76
  throw new Error('Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), ' +
78
77
  'CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), ' +
79
- 'CANVAS_COOKIE (session cookie jar from canvas-parent-mcp-login), ' +
80
78
  'or all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN.');
81
79
  }
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ import { registerDiscussionTools } from './tools/discussions.js';
29
29
  import { registerFileTools } from './tools/files.js';
30
30
  const account = loadAccount();
31
31
  const client = new CanvasClient(account);
32
- const server = new McpServer({ name: 'canvas', version: '0.1.3' });
32
+ const server = new McpServer({ name: 'canvas', version: '1.0.0' });
33
33
  registerProfileTools(server, client);
34
34
  registerObserveeTools(server, client);
35
35
  registerCourseTools(server, client);
@@ -5,9 +5,10 @@
5
5
  // page to capture the CSRF cookie + authenticity_token, then POST credentials
6
6
  // and harvest the resulting session cookies (canvas_session, pseudonym_credentials).
7
7
  //
8
- // The cookie jar is what callers persist as CANVAS_COOKIE. CanvasClient sends
9
- // it on every API request in place of an OAuth bearer token. pseudonym_credentials
10
- // is the "remember me" cookie and is the one with a meaningful expiry (~14 days);
8
+ // The returned cookie jar is held in memory by CanvasClient.sessionCookie and
9
+ // sent on every API request in place of an OAuth bearer token; on a 401 the
10
+ // client re-invokes sessionLogin to mint a fresh jar. pseudonym_credentials
11
+ // is the "remember me" cookie with a meaningful expiry (~14 days);
11
12
  // canvas_session piggybacks on it.
12
13
  const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
13
14
  '(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36';
@@ -115,8 +116,5 @@ export async function sessionLogin(opts) {
115
116
  // Filter to the cookies that actually authenticate API calls. Path/Secure/HttpOnly
116
117
  // attributes are stripped during parseSetCookie, so we just join name=value pairs.
117
118
  const sessionCookies = respCookies.filter((c) => ['canvas_session', 'pseudonym_credentials', '_csrf_token', 'log_session_id'].includes(c.name));
118
- return {
119
- baseUrl,
120
- cookie: serializeCookies(sessionCookies),
121
- };
119
+ return { cookie: serializeCookies(sessionCookies) };
122
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
- "version": "0.1.3",
3
+ "version": "1.0.0",
4
4
  "mcpName": "io.github.chrischall/canvas-parent-mcp",
5
5
  "description": "Canvas LMS (Instructure) MCP server — student/observer read access with optional file download",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -27,8 +27,7 @@
27
27
  "type": "module",
28
28
  "bin": {
29
29
  "canvas-parent-mcp": "dist/index.js",
30
- "canvas-parent-mcp-qr-login": "dist/qr-login-cli.js",
31
- "canvas-parent-mcp-login": "dist/session-login-cli.js"
30
+ "canvas-parent-mcp-qr-login": "dist/qr-login-cli.js"
32
31
  },
33
32
  "files": [
34
33
  "dist",
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": "0.1.3",
9
+ "version": "1.0.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "0.1.3",
14
+ "version": "1.0.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -22,29 +22,42 @@
22
22
  "isRequired": true,
23
23
  "format": "string"
24
24
  },
25
+ {
26
+ "name": "CANVAS_USERNAME",
27
+ "description": "Canvas login username/email (recommended) — pair with CANVAS_PASSWORD for auto-login. No SSO/2FA.",
28
+ "isRequired": false,
29
+ "format": "string"
30
+ },
31
+ {
32
+ "name": "CANVAS_PASSWORD",
33
+ "description": "Canvas password (recommended) — required iff CANVAS_USERNAME is set",
34
+ "isRequired": false,
35
+ "format": "string",
36
+ "isSecret": true
37
+ },
25
38
  {
26
39
  "name": "CANVAS_TOKEN",
27
- "description": "Canvas personal access token (set this OR all three OAuth vars)",
40
+ "description": "Canvas personal access token (advanced) — most schools have disabled token creation",
28
41
  "isRequired": false,
29
42
  "format": "string",
30
43
  "isSecret": true
31
44
  },
32
45
  {
33
46
  "name": "CANVAS_CLIENT_ID",
34
- "description": "Canvas OAuth client ID (required if not using CANVAS_TOKEN)",
47
+ "description": "Canvas OAuth client ID (advanced — for SSO accounts that can't use username/password)",
35
48
  "isRequired": false,
36
49
  "format": "string"
37
50
  },
38
51
  {
39
52
  "name": "CANVAS_CLIENT_SECRET",
40
- "description": "Canvas OAuth client secret (required if not using CANVAS_TOKEN)",
53
+ "description": "Canvas OAuth client secret (advanced)",
41
54
  "isRequired": false,
42
55
  "format": "string",
43
56
  "isSecret": true
44
57
  },
45
58
  {
46
59
  "name": "CANVAS_REFRESH_TOKEN",
47
- "description": "Canvas OAuth refresh token (required if not using CANVAS_TOKEN)",
60
+ "description": "Canvas OAuth refresh token (advanced) — bootstrap via canvas-parent-mcp-qr-login",
48
61
  "isRequired": false,
49
62
  "format": "string",
50
63
  "isSecret": true
@@ -12,7 +12,7 @@ MCP server for Canvas LMS (Instructure) — 18 tools covering profile, observees
12
12
 
13
13
  ## Setup
14
14
 
15
- Two auth modes — set one. Personal access token is recommended.
15
+ Pick one auth mode. **Username/password is recommended** — most schools have disabled personal-access-token creation.
16
16
 
17
17
  ### Option A — Claude Code (direct MCP, no mcporter)
18
18
 
@@ -26,7 +26,8 @@ Add to `.mcp.json` in your project or `~/.claude/mcp.json`:
26
26
  "args": ["-y", "canvas-parent-mcp"],
27
27
  "env": {
28
28
  "CANVAS_BASE_URL": "https://cms.instructure.com",
29
- "CANVAS_TOKEN": "your-personal-access-token",
29
+ "CANVAS_USERNAME": "me@example.com",
30
+ "CANVAS_PASSWORD": "your-canvas-password",
30
31
  "CANVAS_NAME": "CMS"
31
32
  }
32
33
  }
@@ -34,22 +35,27 @@ Add to `.mcp.json` in your project or `~/.claude/mcp.json`:
34
35
  }
35
36
  ```
36
37
 
37
- For OAuth instead of a token:
38
+ The server logs in lazily on the first request and silently re-mints cookies on 401, so you never have to re-bootstrap. Direct Canvas accounts only — won't work with SAML/Google/Microsoft SSO or 2FA.
39
+
40
+ #### Advanced alternatives
41
+
42
+ If your admin still allows tokens, swap `CANVAS_USERNAME`/`CANVAS_PASSWORD` for `CANVAS_TOKEN`:
38
43
 
39
44
  ```json
40
- {
41
- "mcpServers": {
42
- "canvas": {
43
- "command": "npx",
44
- "args": ["-y", "canvas-parent-mcp"],
45
- "env": {
46
- "CANVAS_BASE_URL": "https://cms.instructure.com",
47
- "CANVAS_CLIENT_ID": "...",
48
- "CANVAS_CLIENT_SECRET": "...",
49
- "CANVAS_REFRESH_TOKEN": "..."
50
- }
51
- }
52
- }
45
+ "env": {
46
+ "CANVAS_BASE_URL": "https://cms.instructure.com",
47
+ "CANVAS_TOKEN": "your-personal-access-token"
48
+ }
49
+ ```
50
+
51
+ If your account uses SSO (so username/password won't work), mint OAuth credentials by reusing the Canvas mobile-app QR-login flow (`canvas-parent-mcp-qr-login`), then:
52
+
53
+ ```json
54
+ "env": {
55
+ "CANVAS_BASE_URL": "https://cms.instructure.com",
56
+ "CANVAS_CLIENT_ID": "...",
57
+ "CANVAS_CLIENT_SECRET": "...",
58
+ "CANVAS_REFRESH_TOKEN": "..."
53
59
  }
54
60
  ```
55
61
 
@@ -72,7 +78,7 @@ npm install && npm run build
72
78
 
73
79
  ```bash
74
80
  cp .env.example .env
75
- # Edit .env: set CANVAS_BASE_URL and CANVAS_TOKEN (or the OAuth triple).
81
+ # Edit .env: set CANVAS_BASE_URL plus one of CANVAS_TOKEN, CANVAS_USERNAME+CANVAS_PASSWORD, or the OAuth triple.
76
82
  ```
77
83
 
78
84
  #### 3. Register with mcporter
@@ -81,11 +87,14 @@ cp .env.example .env
81
87
  mcporter config add canvas \
82
88
  --command "canvas-parent-mcp" \
83
89
  --env "CANVAS_BASE_URL=https://cms.instructure.com" \
84
- --env "CANVAS_TOKEN=your-personal-access-token" \
90
+ --env "CANVAS_USERNAME=me@example.com" \
91
+ --env "CANVAS_PASSWORD=your-canvas-password" \
85
92
  --env "CANVAS_NAME=CMS" \
86
93
  --config ~/.mcporter/mcporter.json
87
94
  ```
88
95
 
96
+ (Or substitute `CANVAS_TOKEN` / OAuth env vars per the alternatives above.)
97
+
89
98
  #### 4. Verify
90
99
 
91
100
  ```bash
@@ -189,5 +198,5 @@ Most tools accept an optional `observeeId` (defaults to `self`) — set it to a
189
198
  ## Caution
190
199
 
191
200
  - `canvas_download_file` writes to disk at `destinationPath` — confirm the path with the user; pass `overwrite:true` to replace.
192
- - Personal access tokens grant the same access as the user account they were issued for. Treat them as secrets.
201
+ - All auth credentials (password, token, refresh token) grant the same access as the user account. Treat them as secrets — never commit `.env`.
193
202
  - Endpoints that return paginated results follow Canvas's RFC 5988 `Link` headers automatically up to 50 pages (5,000 items at the default `per_page=100`).
@@ -1,121 +0,0 @@
1
- #!/usr/bin/env node
2
- import { sessionLogin, SessionLoginError } from './session-login.js';
3
- function parseArgs(argv) {
4
- const out = {};
5
- for (let i = 0; i < argv.length; i++) {
6
- const a = argv[i];
7
- if (a === '-h' || a === '--help')
8
- out.help = true;
9
- else if (a === '-b' || a === '--base-url')
10
- out.baseUrl = argv[++i];
11
- else if (a === '-u' || a === '--username')
12
- out.username = argv[++i];
13
- else if (a.startsWith('--base-url='))
14
- out.baseUrl = a.slice('--base-url='.length);
15
- else if (a.startsWith('--username='))
16
- out.username = a.slice('--username='.length);
17
- }
18
- return out;
19
- }
20
- function usage() {
21
- return ('Usage: canvas-parent-mcp-login -b <base-url> -u <username>\n\n' +
22
- 'Logs into Canvas with username/password (no SSO/2FA support) and prints\n' +
23
- 'CANVAS_BASE_URL / CANVAS_COOKIE env vars to stdout. Pipe stdin to supply the\n' +
24
- 'password without a prompt:\n\n' +
25
- ' canvas-parent-mcp-login -b https://cms.instructure.com -u me@example.com <<< "$PW"\n\n' +
26
- 'When stdin is a TTY the tool prompts for the password with no echo.\n' +
27
- 'CANVAS_COOKIE is sensitive — treat it like a password.\n');
28
- }
29
- async function readStdinAll() {
30
- const chunks = [];
31
- for await (const chunk of process.stdin)
32
- chunks.push(chunk);
33
- return Buffer.concat(chunks).toString('utf8');
34
- }
35
- async function readPasswordFromTty() {
36
- return new Promise((resolve, reject) => {
37
- process.stderr.write('Password: ');
38
- const stdin = process.stdin;
39
- stdin.setRawMode(true);
40
- stdin.resume();
41
- stdin.setEncoding('utf8');
42
- let buf = '';
43
- const cleanup = () => {
44
- stdin.setRawMode(false);
45
- stdin.pause();
46
- stdin.removeListener('data', onData);
47
- process.stderr.write('\n');
48
- };
49
- const onData = (data) => {
50
- for (const ch of data) {
51
- if (ch === '\r' || ch === '\n') {
52
- cleanup();
53
- resolve(buf);
54
- return;
55
- }
56
- if (ch === '\u0003') {
57
- // Ctrl+C
58
- cleanup();
59
- reject(new Error('cancelled'));
60
- return;
61
- }
62
- if (ch === '\u007f' || ch === '\b') {
63
- // DEL or backspace
64
- buf = buf.slice(0, -1);
65
- continue;
66
- }
67
- buf += ch;
68
- }
69
- };
70
- stdin.on('data', onData);
71
- });
72
- }
73
- async function readPassword() {
74
- if (process.stdin.isTTY)
75
- return readPasswordFromTty();
76
- return (await readStdinAll()).replace(/\r?\n$/, '');
77
- }
78
- async function main() {
79
- const args = parseArgs(process.argv.slice(2));
80
- if (args.help) {
81
- process.stderr.write(usage());
82
- return 0;
83
- }
84
- if (!args.baseUrl || !args.username) {
85
- process.stderr.write(usage());
86
- return 2;
87
- }
88
- let password;
89
- try {
90
- password = await readPassword();
91
- }
92
- catch {
93
- process.stderr.write('Cancelled.\n');
94
- return 130;
95
- }
96
- if (!password) {
97
- process.stderr.write('Password is empty — refusing to attempt login.\n');
98
- return 2;
99
- }
100
- try {
101
- const result = await sessionLogin({
102
- baseUrl: args.baseUrl,
103
- username: args.username,
104
- password,
105
- });
106
- process.stderr.write(`Logged in to ${result.baseUrl}. Add the following to your .env ` +
107
- '(CANVAS_COOKIE is sensitive — keep it secret):\n\n');
108
- process.stdout.write(`CANVAS_BASE_URL=${result.baseUrl}\n`);
109
- process.stdout.write(`CANVAS_COOKIE=${result.cookie}\n`);
110
- process.stderr.write('\nFor hands-off auto-renewal (re-mints cookies on 401), also add:\n' +
111
- ` CANVAS_USERNAME=${args.username}\n` +
112
- ' CANVAS_PASSWORD=<your password>\n');
113
- return 0;
114
- }
115
- catch (err) {
116
- const msg = err instanceof SessionLoginError ? err.message : err.message;
117
- process.stderr.write(`Login failed: ${msg}\n`);
118
- return 1;
119
- }
120
- }
121
- main().then((code) => process.exit(code));