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