canvas-parent-mcp 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Canvas LMS (Instructure) — courses, grades, assignments, announcements, planner, conversations, files",
10
- "version": "0.1.2"
10
+ "version": "0.1.4"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Canvas LMS",
16
16
  "source": "./",
17
17
  "description": "Canvas LMS (Instructure) MCP server for Claude — student/observer access via natural language",
18
- "version": "0.1.2",
18
+ "version": "0.1.4",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
3
  "displayName": "Canvas LMS",
4
- "version": "0.1.2",
4
+ "version": "0.1.4",
5
5
  "description": "Canvas LMS (Instructure) MCP server for Claude — courses, grades, assignments, announcements, planner, conversations, files",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/.mcp.json CHANGED
@@ -6,6 +6,9 @@
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}",
11
+ "CANVAS_COOKIE": "${CANVAS_COOKIE}",
9
12
  "CANVAS_CLIENT_ID": "${CANVAS_CLIENT_ID}",
10
13
  "CANVAS_CLIENT_SECRET": "${CANVAS_CLIENT_SECRET}",
11
14
  "CANVAS_REFRESH_TOKEN": "${CANVAS_REFRESH_TOKEN}",
package/README.md CHANGED
@@ -37,6 +37,23 @@ CANVAS_TOKEN=your-personal-access-token
37
37
  CANVAS_NAME=cms # optional, defaults to host portion of base URL
38
38
  ```
39
39
 
40
+ **Username/password (auto-login + auto-renew, simplest when tokens are disabled):**
41
+
42
+ ```
43
+ CANVAS_BASE_URL=https://cms.instructure.com
44
+ CANVAS_USERNAME=me@example.com
45
+ CANVAS_PASSWORD=your-canvas-password
46
+ ```
47
+
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.
49
+
50
+ **Session cookie (precomputed, no auto-renewal):**
51
+
52
+ ```
53
+ CANVAS_BASE_URL=https://cms.instructure.com
54
+ CANVAS_COOKIE=canvas_session=...; pseudonym_credentials=...; ...
55
+ ```
56
+
40
57
  **OAuth (advanced):**
41
58
 
42
59
  ```
@@ -46,10 +63,43 @@ CANVAS_CLIENT_SECRET=...
46
63
  CANVAS_REFRESH_TOKEN=...
47
64
  ```
48
65
 
49
- If both modes are set, the token wins.
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.
50
67
 
51
68
  See `.env.example`.
52
69
 
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.
86
+
87
+ ### Bootstrapping OAuth via the mobile QR code
88
+
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:
90
+
91
+ 1. In Canvas web, open **Account → QR for Mobile Login** — Canvas shows a QR that's valid for 10 minutes.
92
+ 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=...`.
93
+ 3. Run the bundled helper:
94
+
95
+ ```
96
+ npx canvas-parent-mcp-qr-login "<decoded-qr-url>" >> .env
97
+ ```
98
+
99
+ It hits Canvas's public `mobile_verify.json` endpoint to fetch the mobile client_id/client_secret, exchanges the QR's one-time code for an access+refresh token pair, and prints `CANVAS_BASE_URL` / `CANVAS_CLIENT_ID` / `CANVAS_CLIENT_SECRET` / `CANVAS_REFRESH_TOKEN` to stdout. The refresh token is sensitive — treat it like a password.
100
+
101
+ This reuses the same SSO + OAuth endpoints the official Canvas Student/Parent apps use; from Canvas's perspective the resulting session looks like a mobile-app session. Use it only against accounts you legitimately control.
102
+
53
103
  ## Status
54
104
 
55
105
  Unofficial — not affiliated with Instructure. AI-maintained.
package/dist/bundle.js CHANGED
@@ -30873,27 +30873,54 @@ 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
+ const username = env.CANVAS_USERNAME;
30878
+ const password = env.CANVAS_PASSWORD;
30876
30879
  const clientId = env.CANVAS_CLIENT_ID;
30877
30880
  const clientSecret = env.CANVAS_CLIENT_SECRET;
30878
30881
  const refreshToken = env.CANVAS_REFRESH_TOKEN;
30879
30882
  const oauthSet = [clientId, clientSecret, refreshToken].filter((v) => !!v);
30880
30883
  const hasFullOAuth = oauthSet.length === 3;
30881
30884
  const hasPartialOAuth = oauthSet.length > 0 && oauthSet.length < 3;
30885
+ const hasFullUserPass = !!username && !!password;
30886
+ const hasPartialUserPass = !!username !== !!password;
30887
+ const hasSession = !!cookie || hasFullUserPass;
30882
30888
  if (token) {
30883
- if (hasFullOAuth) {
30889
+ const others = [hasFullOAuth ? "OAuth" : null, hasSession ? "session env vars" : null].filter(Boolean).join(" and ");
30890
+ if (others) {
30884
30891
  console.error(
30885
- "[canvas-mcp] Both CANVAS_TOKEN and OAuth env vars are set \u2014 using CANVAS_TOKEN. Unset CANVAS_TOKEN to use OAuth."
30892
+ `[canvas-mcp] CANVAS_TOKEN takes precedence over ${others} \u2014 using CANVAS_TOKEN. Unset CANVAS_TOKEN to use ${others}.`
30886
30893
  );
30887
30894
  }
30888
30895
  return { mode: "token", name, baseUrl: cleanBaseUrl, token };
30889
30896
  }
30897
+ if (hasPartialUserPass) {
30898
+ const missing = !username ? "CANVAS_USERNAME" : "CANVAS_PASSWORD";
30899
+ throw new Error(
30900
+ `Incomplete username/password config \u2014 missing: ${missing}. Set both CANVAS_USERNAME and CANVAS_PASSWORD, or unset both.`
30901
+ );
30902
+ }
30903
+ if (hasSession) {
30904
+ if (hasFullOAuth) {
30905
+ 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."
30907
+ );
30908
+ }
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;
30916
+ }
30890
30917
  if (hasPartialOAuth) {
30891
30918
  const missing = [];
30892
30919
  if (!clientId) missing.push("CANVAS_CLIENT_ID");
30893
30920
  if (!clientSecret) missing.push("CANVAS_CLIENT_SECRET");
30894
30921
  if (!refreshToken) missing.push("CANVAS_REFRESH_TOKEN");
30895
30922
  throw new Error(
30896
- `Incomplete OAuth config \u2014 missing: ${missing.join(", ")}. Set all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN (or set CANVAS_TOKEN instead for personal-access-token auth).`
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).`
30897
30924
  );
30898
30925
  }
30899
30926
  if (hasFullOAuth) {
@@ -30908,21 +30935,129 @@ function loadAccount(env = process.env) {
30908
30935
  };
30909
30936
  }
30910
30937
  throw new Error(
30911
- "Missing Canvas auth config. Set either CANVAS_TOKEN (personal access token) or all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN."
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."
30912
30939
  );
30913
30940
  }
30914
30941
 
30915
30942
  // src/client.ts
30916
30943
  import { writeFile, stat } from "fs/promises";
30917
30944
  import { dirname } from "path";
30945
+
30946
+ // src/session-login.ts
30947
+ var USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36";
30948
+ var SessionLoginError = class extends Error {
30949
+ constructor(message) {
30950
+ super(message);
30951
+ this.name = "SessionLoginError";
30952
+ }
30953
+ };
30954
+ function extractAuthenticityToken(html) {
30955
+ const tagRegex = /<input\b[^>]*\bname=["']authenticity_token["'][^>]*>/gi;
30956
+ const matches = html.match(tagRegex);
30957
+ if (!matches) return null;
30958
+ for (const tag of matches) {
30959
+ const valMatch = tag.match(/\bvalue=["']([^"']+)["']/);
30960
+ if (valMatch) return valMatch[1];
30961
+ }
30962
+ return null;
30963
+ }
30964
+ function parseSetCookie(setCookieValue) {
30965
+ const semi = setCookieValue.indexOf(";");
30966
+ const nameValue = semi === -1 ? setCookieValue : setCookieValue.slice(0, semi);
30967
+ const eq = nameValue.indexOf("=");
30968
+ if (eq === -1) return null;
30969
+ const name = nameValue.slice(0, eq).trim();
30970
+ const value = nameValue.slice(eq + 1).trim();
30971
+ if (!name) return null;
30972
+ return { name, value };
30973
+ }
30974
+ function serializeCookies(cookies) {
30975
+ return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
30976
+ }
30977
+ function collectSetCookies(headers) {
30978
+ const out = [];
30979
+ for (const sc of headers.getSetCookie()) {
30980
+ const parsed = parseSetCookie(sc);
30981
+ if (parsed) out.push(parsed);
30982
+ }
30983
+ return out;
30984
+ }
30985
+ async function sessionLogin(opts) {
30986
+ const baseUrl = opts.baseUrl.replace(/\/$/, "");
30987
+ const loginUrl = `${baseUrl}/login/canvas`;
30988
+ const getRes = await fetch(loginUrl, {
30989
+ headers: {
30990
+ "User-Agent": USER_AGENT,
30991
+ Accept: "text/html,application/xhtml+xml"
30992
+ }
30993
+ });
30994
+ if (!getRes.ok) {
30995
+ throw new SessionLoginError(
30996
+ `Login page fetch failed: ${getRes.status} ${getRes.statusText}`
30997
+ );
30998
+ }
30999
+ const html = await getRes.text();
31000
+ const initialCookies = collectSetCookies(getRes.headers);
31001
+ const authToken = extractAuthenticityToken(html);
31002
+ if (!authToken) {
31003
+ throw new SessionLoginError(
31004
+ "Could not find authenticity_token on the login page. This Canvas instance may use SSO (SAML/Google/Microsoft) \u2014 direct password login is not available."
31005
+ );
31006
+ }
31007
+ const body = new URLSearchParams();
31008
+ body.append("utf8", "\u2713");
31009
+ body.append("authenticity_token", authToken);
31010
+ body.append("redirect_to_ssl", "1");
31011
+ body.append("pseudonym_session[unique_id]", opts.username);
31012
+ body.append("pseudonym_session[password]", opts.password);
31013
+ body.append("pseudonym_session[remember_me]", "0");
31014
+ body.append("pseudonym_session[remember_me]", "1");
31015
+ const postRes = await fetch(loginUrl, {
31016
+ method: "POST",
31017
+ redirect: "manual",
31018
+ headers: {
31019
+ "Content-Type": "application/x-www-form-urlencoded",
31020
+ Accept: "text/html,application/xhtml+xml",
31021
+ "User-Agent": USER_AGENT,
31022
+ Cookie: serializeCookies(initialCookies),
31023
+ Origin: baseUrl,
31024
+ Referer: loginUrl
31025
+ },
31026
+ body: body.toString()
31027
+ });
31028
+ const respCookies = collectSetCookies(postRes.headers);
31029
+ const hasCreds = respCookies.some((c) => c.name === "pseudonym_credentials");
31030
+ if (!hasCreds) {
31031
+ const location = postRes.headers.get("location") ?? "";
31032
+ if (location && !location.startsWith(baseUrl) && !location.includes("sso.canvaslms.com")) {
31033
+ throw new SessionLoginError(
31034
+ `Login redirected to an external identity provider (${location.slice(0, 100)}). This Canvas account requires SSO/2FA \u2014 direct password login is not supported.`
31035
+ );
31036
+ }
31037
+ throw new SessionLoginError(
31038
+ "Login response did not include a pseudonym_credentials cookie \u2014 incorrect username or password, or the account is locked."
31039
+ );
31040
+ }
31041
+ const sessionCookies = respCookies.filter(
31042
+ (c) => ["canvas_session", "pseudonym_credentials", "_csrf_token", "log_session_id"].includes(c.name)
31043
+ );
31044
+ return {
31045
+ baseUrl,
31046
+ cookie: serializeCookies(sessionCookies)
31047
+ };
31048
+ }
31049
+
31050
+ // src/client.ts
30918
31051
  var DEFAULT_PER_PAGE = 100;
30919
31052
  var DEFAULT_MAX_PAGES = 50;
30920
31053
  var CanvasClient = class {
30921
31054
  account;
30922
31055
  refreshInFlight = null;
30923
31056
  accessTokenExpiresAt = 0;
30924
- constructor(account2) {
31057
+ sessionLoginFn;
31058
+ constructor(account2, opts = {}) {
30925
31059
  this.account = account2;
31060
+ this.sessionLoginFn = opts.sessionLogin ?? sessionLogin;
30926
31061
  }
30927
31062
  /** Account metadata (no secrets) — useful for diagnostics. */
30928
31063
  describe() {
@@ -30965,10 +31100,10 @@ var CanvasClient = class {
30965
31100
  } catch {
30966
31101
  throw new ParentDirectoryMissingError(parent);
30967
31102
  }
30968
- await this.ensureToken();
31103
+ await this.ensureAuth();
30969
31104
  const url2 = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
30970
31105
  const res = await fetch(url2, {
30971
- headers: { Authorization: `Bearer ${this.getAccessToken()}` }
31106
+ headers: this.getAuthHeaders()
30972
31107
  });
30973
31108
  if (res.status === 404) throw new Error(`Canvas download 404 for ${path}`);
30974
31109
  if (!res.ok) throw new Error(`Canvas download ${res.status} for ${path}`);
@@ -30981,23 +31116,23 @@ var CanvasClient = class {
30981
31116
  };
30982
31117
  }
30983
31118
  async doRawRequest(path, opts, isRetry) {
30984
- await this.ensureToken();
31119
+ await this.ensureAuth();
30985
31120
  const url2 = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
30986
31121
  const accept = opts.responseType === "text" ? "text/html, text/plain, */*" : "application/json+canvas-string-ids, application/json";
30987
31122
  const res = await fetch(url2, {
30988
31123
  method: opts.method ?? "GET",
30989
31124
  headers: {
30990
- Authorization: `Bearer ${this.getAccessToken()}`,
31125
+ ...this.getAuthHeaders(),
30991
31126
  Accept: accept,
30992
31127
  ...opts.headers ?? {}
30993
31128
  },
30994
31129
  body: opts.body
30995
31130
  });
30996
31131
  if (res.status === 401) {
30997
- if (isRetry || this.account.mode === "token") {
31132
+ if (isRetry || !this.canRefreshAuth()) {
30998
31133
  throw new TokenExpiredError(this.account.mode);
30999
31134
  }
31000
- await this.ensureToken({ force: true });
31135
+ await this.ensureAuth({ force: true });
31001
31136
  return this.doRawRequest(path, opts, true);
31002
31137
  }
31003
31138
  if (res.status === 404) throw new Error(`Canvas 404 ${path}`);
@@ -31005,25 +31140,48 @@ var CanvasClient = class {
31005
31140
  if (!res.ok) throw new Error(`Canvas ${res.status} ${res.statusText} for ${path}`);
31006
31141
  return res;
31007
31142
  }
31008
- getAccessToken() {
31009
- if (this.account.mode === "token") return this.account.token;
31010
- return this.account.accessToken;
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
+ getAuthHeaders() {
31151
+ const acct = this.account;
31152
+ if (acct.mode === "token") return { Authorization: `Bearer ${acct.token}` };
31153
+ if (acct.mode === "session") return { Cookie: acct.cookie };
31154
+ return { Authorization: `Bearer ${acct.accessToken}` };
31011
31155
  }
31012
- async ensureToken(opts = {}) {
31156
+ async ensureAuth(opts = {}) {
31013
31157
  const acct = this.account;
31014
31158
  if (acct.mode === "token") return;
31015
- if (!opts.force && acct.accessToken && Date.now() < this.accessTokenExpiresAt) return;
31159
+ if (!opts.force) {
31160
+ 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");
31165
+ }
31016
31166
  if (this.refreshInFlight) {
31017
31167
  await this.refreshInFlight;
31018
31168
  return;
31019
31169
  }
31020
- this.refreshInFlight = this.refreshAccessToken(acct);
31170
+ this.refreshInFlight = acct.mode === "oauth" ? this.refreshAccessToken(acct) : this.mintSessionCookie(acct);
31021
31171
  try {
31022
31172
  await this.refreshInFlight;
31023
31173
  } finally {
31024
31174
  this.refreshInFlight = null;
31025
31175
  }
31026
31176
  }
31177
+ async mintSessionCookie(acct) {
31178
+ const result = await this.sessionLoginFn({
31179
+ baseUrl: acct.baseUrl,
31180
+ username: acct.username,
31181
+ password: acct.password
31182
+ });
31183
+ acct.cookie = result.cookie;
31184
+ }
31027
31185
  async refreshAccessToken(acct) {
31028
31186
  const body = new URLSearchParams({
31029
31187
  grant_type: "refresh_token",
@@ -31066,7 +31224,7 @@ function parseLinkHeader(header) {
31066
31224
  }
31067
31225
  var TokenExpiredError = class extends Error {
31068
31226
  constructor(mode, detail) {
31069
- const base = mode === "token" ? "Canvas access token rejected (401). Check CANVAS_TOKEN \u2014 it may be expired or revoked." : "Canvas OAuth refresh failed. Check CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN.";
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.";
31070
31228
  super(detail ? `${base} (${detail})` : base);
31071
31229
  this.mode = mode;
31072
31230
  this.detail = detail;
@@ -31484,7 +31642,7 @@ try {
31484
31642
  }
31485
31643
  var account = loadAccount();
31486
31644
  var client = new CanvasClient(account);
31487
- var server = new McpServer({ name: "canvas", version: "0.1.2" });
31645
+ var server = new McpServer({ name: "canvas", version: "0.1.4" });
31488
31646
  registerProfileTools(server, client);
31489
31647
  registerObserveeTools(server, client);
31490
31648
  registerCourseTools(server, client);
package/dist/client.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { writeFile, stat } from 'fs/promises';
2
2
  import { dirname } from 'path';
3
+ import { sessionLogin as defaultSessionLogin } from './session-login.js';
3
4
  const DEFAULT_PER_PAGE = 100;
4
5
  const DEFAULT_MAX_PAGES = 50;
5
6
  export class CanvasClient {
6
7
  account;
7
8
  refreshInFlight = null;
8
9
  accessTokenExpiresAt = 0;
9
- constructor(account) {
10
+ sessionLoginFn;
11
+ constructor(account, opts = {}) {
10
12
  this.account = account;
13
+ this.sessionLoginFn = opts.sessionLogin ?? defaultSessionLogin;
11
14
  }
12
15
  /** Account metadata (no secrets) — useful for diagnostics. */
13
16
  describe() {
@@ -56,10 +59,10 @@ export class CanvasClient {
56
59
  catch {
57
60
  throw new ParentDirectoryMissingError(parent);
58
61
  }
59
- await this.ensureToken();
62
+ await this.ensureAuth();
60
63
  const url = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
61
64
  const res = await fetch(url, {
62
- headers: { Authorization: `Bearer ${this.getAccessToken()}` },
65
+ headers: this.getAuthHeaders(),
63
66
  });
64
67
  if (res.status === 404)
65
68
  throw new Error(`Canvas download 404 for ${path}`);
@@ -74,7 +77,7 @@ export class CanvasClient {
74
77
  };
75
78
  }
76
79
  async doRawRequest(path, opts, isRetry) {
77
- await this.ensureToken();
80
+ await this.ensureAuth();
78
81
  const url = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
79
82
  const accept = opts.responseType === 'text'
80
83
  ? 'text/html, text/plain, */*'
@@ -82,17 +85,17 @@ export class CanvasClient {
82
85
  const res = await fetch(url, {
83
86
  method: opts.method ?? 'GET',
84
87
  headers: {
85
- Authorization: `Bearer ${this.getAccessToken()}`,
88
+ ...this.getAuthHeaders(),
86
89
  Accept: accept,
87
90
  ...(opts.headers ?? {}),
88
91
  },
89
92
  body: opts.body,
90
93
  });
91
94
  if (res.status === 401) {
92
- if (isRetry || this.account.mode === 'token') {
95
+ if (isRetry || !this.canRefreshAuth()) {
93
96
  throw new TokenExpiredError(this.account.mode);
94
97
  }
95
- await this.ensureToken({ force: true });
98
+ await this.ensureAuth({ force: true });
96
99
  return this.doRawRequest(path, opts, true);
97
100
  }
98
101
  if (res.status === 404)
@@ -103,24 +106,46 @@ export class CanvasClient {
103
106
  throw new Error(`Canvas ${res.status} ${res.statusText} for ${path}`);
104
107
  return res;
105
108
  }
106
- getAccessToken() {
107
- if (this.account.mode === 'token')
108
- return this.account.token;
109
- // OAuth mode: callers always invoke ensureToken() first, which guarantees accessToken is set.
110
- return this.account.accessToken;
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;
111
117
  }
112
- async ensureToken(opts = {}) {
118
+ getAuthHeaders() {
119
+ const acct = this.account;
120
+ // For oauth/session: callers always invoke ensureAuth() first, which guarantees the credential is set.
121
+ if (acct.mode === 'token')
122
+ return { Authorization: `Bearer ${acct.token}` };
123
+ if (acct.mode === 'session')
124
+ return { Cookie: acct.cookie };
125
+ return { Authorization: `Bearer ${acct.accessToken}` };
126
+ }
127
+ async ensureAuth(opts = {}) {
113
128
  const acct = this.account;
114
- // Token mode: nothing to refresh. doRawRequest throws on 401 before reaching here with force=true.
115
129
  if (acct.mode === 'token')
116
130
  return;
117
- if (!opts.force && acct.accessToken && Date.now() < this.accessTokenExpiresAt)
118
- return;
131
+ // Decide whether the cached creds are still good.
132
+ if (!opts.force) {
133
+ if (acct.mode === 'oauth' && acct.accessToken && Date.now() < this.accessTokenExpiresAt)
134
+ return;
135
+ if (acct.mode === 'session' && acct.cookie)
136
+ return;
137
+ }
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
+ }
119
142
  if (this.refreshInFlight) {
120
143
  await this.refreshInFlight;
121
144
  return;
122
145
  }
123
- this.refreshInFlight = this.refreshAccessToken(acct);
146
+ this.refreshInFlight = acct.mode === 'oauth'
147
+ ? this.refreshAccessToken(acct)
148
+ : this.mintSessionCookie(acct);
124
149
  try {
125
150
  await this.refreshInFlight;
126
151
  }
@@ -128,6 +153,14 @@ export class CanvasClient {
128
153
  this.refreshInFlight = null;
129
154
  }
130
155
  }
156
+ async mintSessionCookie(acct) {
157
+ const result = await this.sessionLoginFn({
158
+ baseUrl: acct.baseUrl,
159
+ username: acct.username,
160
+ password: acct.password,
161
+ });
162
+ acct.cookie = result.cookie;
163
+ }
131
164
  async refreshAccessToken(acct) {
132
165
  const body = new URLSearchParams({
133
166
  grant_type: 'refresh_token',
@@ -183,7 +216,9 @@ export class TokenExpiredError extends Error {
183
216
  constructor(mode, detail) {
184
217
  const base = mode === 'token'
185
218
  ? 'Canvas access token rejected (401). Check CANVAS_TOKEN — it may be expired or revoked.'
186
- : 'Canvas OAuth refresh failed. Check CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN.';
219
+ : mode === 'session'
220
+ ? 'Canvas session cookie rejected (401). Re-run canvas-parent-mcp-login to mint a fresh CANVAS_COOKIE.'
221
+ : 'Canvas OAuth refresh failed. Check CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN.';
187
222
  super(detail ? `${base} (${detail})` : base);
188
223
  this.mode = mode;
189
224
  this.detail = detail;
package/dist/config.js CHANGED
@@ -10,19 +10,47 @@ 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
+ const username = env.CANVAS_USERNAME;
15
+ const password = env.CANVAS_PASSWORD;
13
16
  const clientId = env.CANVAS_CLIENT_ID;
14
17
  const clientSecret = env.CANVAS_CLIENT_SECRET;
15
18
  const refreshToken = env.CANVAS_REFRESH_TOKEN;
16
19
  const oauthSet = [clientId, clientSecret, refreshToken].filter((v) => !!v);
17
20
  const hasFullOAuth = oauthSet.length === 3;
18
21
  const hasPartialOAuth = oauthSet.length > 0 && oauthSet.length < 3;
22
+ const hasFullUserPass = !!username && !!password;
23
+ const hasPartialUserPass = (!!username) !== (!!password);
24
+ const hasSession = !!cookie || hasFullUserPass;
19
25
  if (token) {
20
- if (hasFullOAuth) {
21
- console.error('[canvas-mcp] Both CANVAS_TOKEN and OAuth env vars are set — using CANVAS_TOKEN. ' +
22
- 'Unset CANVAS_TOKEN to use OAuth.');
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}.`);
23
32
  }
24
33
  return { mode: 'token', name, baseUrl: cleanBaseUrl, token };
25
34
  }
35
+ if (hasPartialUserPass) {
36
+ const missing = !username ? 'CANVAS_USERNAME' : 'CANVAS_PASSWORD';
37
+ throw new Error(`Incomplete username/password config — missing: ${missing}. ` +
38
+ 'Set both CANVAS_USERNAME and CANVAS_PASSWORD, or unset both.');
39
+ }
40
+ if (hasSession) {
41
+ 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;
51
+ }
52
+ return acct;
53
+ }
26
54
  if (hasPartialOAuth) {
27
55
  const missing = [];
28
56
  if (!clientId)
@@ -33,7 +61,7 @@ export function loadAccount(env = process.env) {
33
61
  missing.push('CANVAS_REFRESH_TOKEN');
34
62
  throw new Error(`Incomplete OAuth config — missing: ${missing.join(', ')}. ` +
35
63
  'Set all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN ' +
36
- '(or set CANVAS_TOKEN instead for personal-access-token auth).');
64
+ '(or set CANVAS_TOKEN / CANVAS_COOKIE / CANVAS_USERNAME+CANVAS_PASSWORD instead).');
37
65
  }
38
66
  if (hasFullOAuth) {
39
67
  return {
@@ -46,6 +74,8 @@ export function loadAccount(env = process.env) {
46
74
  accessToken: env.CANVAS_ACCESS_TOKEN,
47
75
  };
48
76
  }
49
- throw new Error('Missing Canvas auth config. Set either CANVAS_TOKEN (personal access token) ' +
77
+ throw new Error('Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), ' +
78
+ 'CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), ' +
79
+ 'CANVAS_COOKIE (session cookie jar from canvas-parent-mcp-login), ' +
50
80
  'or all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN.');
51
81
  }
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.2' });
32
+ const server = new McpServer({ name: 'canvas', version: '0.1.4' });
33
33
  registerProfileTools(server, client);
34
34
  registerObserveeTools(server, client);
35
35
  registerCourseTools(server, client);
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { qrLogin, QrLoginError } from './qr-login.js';
3
+ async function readStdin() {
4
+ const chunks = [];
5
+ for await (const chunk of process.stdin)
6
+ chunks.push(chunk);
7
+ return Buffer.concat(chunks).toString('utf8').trim();
8
+ }
9
+ async function main() {
10
+ const arg = process.argv[2];
11
+ if (arg === '-h' || arg === '--help') {
12
+ process.stderr.write(usage());
13
+ return 0;
14
+ }
15
+ const qrUrl = arg ?? (process.stdin.isTTY ? '' : await readStdin());
16
+ if (!qrUrl) {
17
+ process.stderr.write(usage());
18
+ return 2;
19
+ }
20
+ try {
21
+ const result = await qrLogin(qrUrl);
22
+ process.stderr.write(`Logged in as ${result.user.name} (id ${result.user.id}) on ${result.baseUrl}\n` +
23
+ `Add the following to your .env (refresh token is sensitive — keep it secret):\n\n`);
24
+ process.stdout.write(`CANVAS_BASE_URL=${result.baseUrl}\n`);
25
+ process.stdout.write(`CANVAS_CLIENT_ID=${result.clientId}\n`);
26
+ process.stdout.write(`CANVAS_CLIENT_SECRET=${result.clientSecret}\n`);
27
+ process.stdout.write(`CANVAS_REFRESH_TOKEN=${result.refreshToken}\n`);
28
+ return 0;
29
+ }
30
+ catch (err) {
31
+ const msg = err instanceof QrLoginError ? err.message : err.message;
32
+ process.stderr.write(`QR login failed: ${msg}\n`);
33
+ return 1;
34
+ }
35
+ }
36
+ function usage() {
37
+ return ('Usage: canvas-parent-mcp-qr-login <qr-url>\n' +
38
+ ' or: echo "<qr-url>" | canvas-parent-mcp-qr-login\n\n' +
39
+ 'Decode the QR shown at <canvas>/profile/qr_mobile_login (any QR-reader app),\n' +
40
+ 'then pass the resulting URL. Outputs CANVAS_* env vars to stdout.\n');
41
+ }
42
+ main().then((code) => process.exit(code));
@@ -0,0 +1,108 @@
1
+ // Canvas mobile QR login flow — mirrors the canvas-ios reference implementation
2
+ // (Core/Core/Features/Login/{MobileVerify,APIOAuth,GetSSOLogin}.swift).
3
+ //
4
+ // 1. The QR encodes: https://sso.canvaslms.com/canvas/login?domain=<host>&code=<one-time>
5
+ // 2. mobile_verify.json hands out the mobile client_id/client_secret for that domain.
6
+ // 3. POST /login/oauth2/token (authorization_code grant) trades the code for access+refresh tokens.
7
+ //
8
+ // Once we have the refresh_token + client_id + client_secret, the existing CanvasClient
9
+ // OAuth refresh path takes over for ongoing use.
10
+ const SSO_HOSTS = ['sso.canvaslms.com', 'sso.beta.canvaslms.com', 'sso.test.canvaslms.com'];
11
+ export class QrLoginError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = 'QrLoginError';
15
+ }
16
+ }
17
+ export function parseQrLoginUrl(qrUrl) {
18
+ let url;
19
+ try {
20
+ url = new URL(qrUrl);
21
+ }
22
+ catch {
23
+ throw new QrLoginError(`Not a valid URL: ${qrUrl}`);
24
+ }
25
+ if (url.protocol !== 'https:') {
26
+ throw new QrLoginError(`QR URL must be https, got ${url.protocol}`);
27
+ }
28
+ if (!SSO_HOSTS.includes(url.hostname)) {
29
+ throw new QrLoginError(`Unrecognized SSO host '${url.hostname}'; expected one of ${SSO_HOSTS.join(', ')}`);
30
+ }
31
+ if (url.pathname !== '/canvas/login') {
32
+ throw new QrLoginError(`Expected path /canvas/login, got '${url.pathname}'`);
33
+ }
34
+ const rawDomain = url.searchParams.get('domain');
35
+ if (!rawDomain)
36
+ throw new QrLoginError("QR URL is missing 'domain' query parameter");
37
+ const code = url.searchParams.get('code');
38
+ if (!code)
39
+ throw new QrLoginError("QR URL is missing 'code' query parameter");
40
+ return { ssoHost: url.hostname, domain: normalizeDomain(rawDomain), code };
41
+ }
42
+ function normalizeDomain(raw) {
43
+ const stripped = raw.replace(/^https?:\/\//, '').replace(/\/$/, '');
44
+ return stripped;
45
+ }
46
+ export async function fetchMobileClient(domain, ssoHost) {
47
+ const url = `https://${ssoHost}/api/v1/mobile_verify.json?domain=${encodeURIComponent(domain)}`;
48
+ const res = await fetch(url, { headers: { Accept: 'application/json' } });
49
+ if (!res.ok) {
50
+ const body = (await res.text()).slice(0, 200);
51
+ throw new QrLoginError(`mobile_verify failed: ${res.status} ${res.statusText}: ${body}`);
52
+ }
53
+ const data = (await res.json());
54
+ if (data.authorized !== true) {
55
+ throw new QrLoginError(`mobile_verify returned not authorized for domain '${domain}'. ` +
56
+ 'The host may not allow mobile logins.');
57
+ }
58
+ if (!data.base_url) {
59
+ throw new QrLoginError(`mobile_verify response is missing base_url for domain '${domain}'`);
60
+ }
61
+ if (!data.client_id || !data.client_secret) {
62
+ throw new QrLoginError(`mobile_verify response is missing mobile client credentials for domain '${domain}'`);
63
+ }
64
+ return {
65
+ baseUrl: data.base_url.replace(/\/$/, ''),
66
+ clientId: data.client_id,
67
+ clientSecret: data.client_secret,
68
+ };
69
+ }
70
+ export async function exchangeAuthCode(client, code) {
71
+ const res = await fetch(`${client.baseUrl}/login/oauth2/token`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
74
+ body: JSON.stringify({
75
+ client_id: client.clientId,
76
+ client_secret: client.clientSecret,
77
+ grant_type: 'authorization_code',
78
+ code,
79
+ }),
80
+ });
81
+ if (!res.ok) {
82
+ const body = (await res.text()).slice(0, 200);
83
+ throw new QrLoginError(`Token exchange failed: ${res.status} ${res.statusText}: ${body}`);
84
+ }
85
+ const data = (await res.json());
86
+ if (!data.access_token)
87
+ throw new QrLoginError('Token response missing access_token');
88
+ if (!data.refresh_token) {
89
+ throw new QrLoginError('Token response missing refresh_token — cannot persist this session. ' +
90
+ 'The QR code may be one-shot only on this Canvas instance.');
91
+ }
92
+ if (!data.user)
93
+ throw new QrLoginError('Token response missing user');
94
+ const result = {
95
+ accessToken: data.access_token,
96
+ refreshToken: data.refresh_token,
97
+ user: data.user,
98
+ };
99
+ if (data.expires_in !== undefined)
100
+ result.expiresIn = data.expires_in;
101
+ return result;
102
+ }
103
+ export async function qrLogin(qrUrl) {
104
+ const parsed = parseQrLoginUrl(qrUrl);
105
+ const client = await fetchMobileClient(parsed.domain, parsed.ssoHost);
106
+ const tokens = await exchangeAuthCode(client, parsed.code);
107
+ return { ...client, ...tokens };
108
+ }
@@ -0,0 +1,121 @@
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));
@@ -0,0 +1,122 @@
1
+ // Username/password login via the Canvas web form (/login/canvas).
2
+ //
3
+ // Required when the institution has restricted personal-access-token creation
4
+ // to the official mobile app. We reproduce a browser session: GET the login
5
+ // page to capture the CSRF cookie + authenticity_token, then POST credentials
6
+ // and harvest the resulting session cookies (canvas_session, pseudonym_credentials).
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);
11
+ // canvas_session piggybacks on it.
12
+ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
13
+ '(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36';
14
+ export class SessionLoginError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'SessionLoginError';
18
+ }
19
+ }
20
+ export function extractAuthenticityToken(html) {
21
+ const tagRegex = /<input\b[^>]*\bname=["']authenticity_token["'][^>]*>/gi;
22
+ const matches = html.match(tagRegex);
23
+ if (!matches)
24
+ return null;
25
+ for (const tag of matches) {
26
+ const valMatch = tag.match(/\bvalue=["']([^"']+)["']/);
27
+ if (valMatch)
28
+ return valMatch[1];
29
+ }
30
+ return null;
31
+ }
32
+ export function parseSetCookie(setCookieValue) {
33
+ const semi = setCookieValue.indexOf(';');
34
+ const nameValue = semi === -1 ? setCookieValue : setCookieValue.slice(0, semi);
35
+ const eq = nameValue.indexOf('=');
36
+ if (eq === -1)
37
+ return null;
38
+ const name = nameValue.slice(0, eq).trim();
39
+ const value = nameValue.slice(eq + 1).trim();
40
+ if (!name)
41
+ return null;
42
+ return { name, value };
43
+ }
44
+ export function serializeCookies(cookies) {
45
+ return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
46
+ }
47
+ function collectSetCookies(headers) {
48
+ const out = [];
49
+ // Node 18+: Headers.getSetCookie() returns each Set-Cookie as a separate string.
50
+ for (const sc of headers.getSetCookie()) {
51
+ const parsed = parseSetCookie(sc);
52
+ if (parsed)
53
+ out.push(parsed);
54
+ }
55
+ return out;
56
+ }
57
+ export async function sessionLogin(opts) {
58
+ const baseUrl = opts.baseUrl.replace(/\/$/, '');
59
+ const loginUrl = `${baseUrl}/login/canvas`;
60
+ // 1. GET the login page to capture the CSRF cookie + authenticity_token form value.
61
+ const getRes = await fetch(loginUrl, {
62
+ headers: {
63
+ 'User-Agent': USER_AGENT,
64
+ Accept: 'text/html,application/xhtml+xml',
65
+ },
66
+ });
67
+ if (!getRes.ok) {
68
+ throw new SessionLoginError(`Login page fetch failed: ${getRes.status} ${getRes.statusText}`);
69
+ }
70
+ const html = await getRes.text();
71
+ const initialCookies = collectSetCookies(getRes.headers);
72
+ const authToken = extractAuthenticityToken(html);
73
+ if (!authToken) {
74
+ throw new SessionLoginError("Could not find authenticity_token on the login page. " +
75
+ "This Canvas instance may use SSO (SAML/Google/Microsoft) — direct password login is not available.");
76
+ }
77
+ // 2. POST the credentials. Mirror the field set Canvas's web form submits, including
78
+ // the duplicate remember_me=0/1 (Rails idiom for unchecked-then-checked) so the
79
+ // response carries a long-lived pseudonym_credentials cookie.
80
+ const body = new URLSearchParams();
81
+ body.append('utf8', '✓');
82
+ body.append('authenticity_token', authToken);
83
+ body.append('redirect_to_ssl', '1');
84
+ body.append('pseudonym_session[unique_id]', opts.username);
85
+ body.append('pseudonym_session[password]', opts.password);
86
+ body.append('pseudonym_session[remember_me]', '0');
87
+ body.append('pseudonym_session[remember_me]', '1');
88
+ const postRes = await fetch(loginUrl, {
89
+ method: 'POST',
90
+ redirect: 'manual',
91
+ headers: {
92
+ 'Content-Type': 'application/x-www-form-urlencoded',
93
+ Accept: 'text/html,application/xhtml+xml',
94
+ 'User-Agent': USER_AGENT,
95
+ Cookie: serializeCookies(initialCookies),
96
+ Origin: baseUrl,
97
+ Referer: loginUrl,
98
+ },
99
+ body: body.toString(),
100
+ });
101
+ // 3. Verify success by looking for the pseudonym_credentials cookie. Canvas sets it
102
+ // only when authentication actually succeeded; a failed login returns a fresh
103
+ // csrf token but no credentials cookie.
104
+ const respCookies = collectSetCookies(postRes.headers);
105
+ const hasCreds = respCookies.some((c) => c.name === 'pseudonym_credentials');
106
+ if (!hasCreds) {
107
+ const location = postRes.headers.get('location') ?? '';
108
+ if (location && !location.startsWith(baseUrl) && !location.includes('sso.canvaslms.com')) {
109
+ throw new SessionLoginError(`Login redirected to an external identity provider (${location.slice(0, 100)}). ` +
110
+ 'This Canvas account requires SSO/2FA — direct password login is not supported.');
111
+ }
112
+ throw new SessionLoginError('Login response did not include a pseudonym_credentials cookie — ' +
113
+ 'incorrect username or password, or the account is locked.');
114
+ }
115
+ // Filter to the cookies that actually authenticate API calls. Path/Secure/HttpOnly
116
+ // attributes are stripped during parseSetCookie, so we just join name=value pairs.
117
+ 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
+ };
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvas-parent-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "mcpName": "io.github.chrischall/canvas-parent-mcp",
5
5
  "description": "Canvas LMS (Instructure) MCP server — student/observer read access with optional file download",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -26,7 +26,9 @@
26
26
  ],
27
27
  "type": "module",
28
28
  "bin": {
29
- "canvas-parent-mcp": "dist/index.js"
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
32
  },
31
33
  "files": [
32
34
  "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.2",
9
+ "version": "0.1.4",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "0.1.2",
14
+ "version": "0.1.4",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -24,7 +24,27 @@
24
24
  },
25
25
  {
26
26
  "name": "CANVAS_TOKEN",
27
- "description": "Canvas personal access token (set this OR all three OAuth vars)",
27
+ "description": "Canvas personal access token (pick ONE auth mode: this, USERNAME+PASSWORD, COOKIE, or OAuth triple)",
28
+ "isRequired": false,
29
+ "format": "string",
30
+ "isSecret": true
31
+ },
32
+ {
33
+ "name": "CANVAS_USERNAME",
34
+ "description": "Canvas login username/email — pair with CANVAS_PASSWORD for auto-login (no SSO/2FA)",
35
+ "isRequired": false,
36
+ "format": "string"
37
+ },
38
+ {
39
+ "name": "CANVAS_PASSWORD",
40
+ "description": "Canvas password for auto-login — required iff CANVAS_USERNAME is set",
41
+ "isRequired": false,
42
+ "format": "string",
43
+ "isSecret": true
44
+ },
45
+ {
46
+ "name": "CANVAS_COOKIE",
47
+ "description": "Pre-minted Canvas session cookie jar (from canvas-parent-mcp-login or qr-login CLI)",
28
48
  "isRequired": false,
29
49
  "format": "string",
30
50
  "isSecret": true
@@ -34,7 +34,25 @@ Add to `.mcp.json` in your project or `~/.claude/mcp.json`:
34
34
  }
35
35
  ```
36
36
 
37
- For OAuth instead of a token:
37
+ When admins have disabled token creation, use username/password (auto-login + auto-renew):
38
+
39
+ ```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_USERNAME": "me@example.com",
48
+ "CANVAS_PASSWORD": "your-canvas-password"
49
+ }
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Direct Canvas accounts only — won't work with SAML/Google/Microsoft SSO or 2FA. Or, for OAuth instead of a token:
38
56
 
39
57
  ```json
40
58
  {
@@ -72,7 +90,7 @@ npm install && npm run build
72
90
 
73
91
  ```bash
74
92
  cp .env.example .env
75
- # Edit .env: set CANVAS_BASE_URL and CANVAS_TOKEN (or the OAuth triple).
93
+ # Edit .env: set CANVAS_BASE_URL plus one of CANVAS_TOKEN, CANVAS_USERNAME+CANVAS_PASSWORD, CANVAS_COOKIE, or the OAuth triple.
76
94
  ```
77
95
 
78
96
  #### 3. Register with mcporter