canvas-parent-mcp 0.1.2 → 0.1.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +51 -1
- package/dist/bundle.js +177 -19
- package/dist/client.js +53 -18
- package/dist/config.js +35 -5
- package/dist/index.js +1 -1
- package/dist/qr-login-cli.js +42 -0
- package/dist/qr-login.js +108 -0
- package/dist/session-login-cli.js +121 -0
- package/dist/session-login.js +122 -0
- package/package.json +4 -2
- package/server.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Canvas LMS (Instructure) — courses, grades, assignments, announcements, planner, conversations, files",
|
|
10
|
-
"version": "0.1.
|
|
10
|
+
"version": "0.1.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": "0.1.
|
|
18
|
+
"version": "0.1.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": "0.1.
|
|
4
|
+
"version": "0.1.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/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
|
|
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
|
-
|
|
30889
|
+
const others = [hasFullOAuth ? "OAuth" : null, hasSession ? "session env vars" : null].filter(Boolean).join(" and ");
|
|
30890
|
+
if (others) {
|
|
30884
30891
|
console.error(
|
|
30885
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
31132
|
+
if (isRetry || !this.canRefreshAuth()) {
|
|
30998
31133
|
throw new TokenExpiredError(this.account.mode);
|
|
30999
31134
|
}
|
|
31000
|
-
await this.
|
|
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
|
-
|
|
31009
|
-
|
|
31010
|
-
|
|
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
|
|
31156
|
+
async ensureAuth(opts = {}) {
|
|
31013
31157
|
const acct = this.account;
|
|
31014
31158
|
if (acct.mode === "token") return;
|
|
31015
|
-
if (!opts.force
|
|
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.
|
|
31645
|
+
var server = new McpServer({ name: "canvas", version: "0.1.3" });
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
95
|
+
if (isRetry || !this.canRefreshAuth()) {
|
|
93
96
|
throw new TokenExpiredError(this.account.mode);
|
|
94
97
|
}
|
|
95
|
-
await this.
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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 =
|
|
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
|
-
:
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
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.
|
|
32
|
+
const server = new McpServer({ name: 'canvas', version: '0.1.3' });
|
|
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));
|
package/dist/qr-login.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.1.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>",
|
|
@@ -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.
|
|
9
|
+
"version": "0.1.3",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "canvas-parent-mcp",
|
|
14
|
-
"version": "0.1.
|
|
14
|
+
"version": "0.1.3",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|