canvas-parent-mcp 0.1.4 → 1.0.1
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 +0 -1
- package/README.md +20 -32
- package/dist/bundle.js +64 -64
- package/dist/client.js +31 -39
- package/dist/config.js +47 -31
- package/dist/index.js +1 -1
- package/dist/session-login.js +5 -7
- package/package.json +2 -3
- package/server.json +9 -16
- package/skills/canvas/SKILL.md +23 -32
- 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.1
|
|
10
|
+
"version": "1.0.1"
|
|
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": "1.0.1",
|
|
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": "1.0.1",
|
|
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
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
"CANVAS_TOKEN": "${CANVAS_TOKEN}",
|
|
9
9
|
"CANVAS_USERNAME": "${CANVAS_USERNAME}",
|
|
10
10
|
"CANVAS_PASSWORD": "${CANVAS_PASSWORD}",
|
|
11
|
-
"CANVAS_COOKIE": "${CANVAS_COOKIE}",
|
|
12
11
|
"CANVAS_CLIENT_ID": "${CANVAS_CLIENT_ID}",
|
|
13
12
|
"CANVAS_CLIENT_SECRET": "${CANVAS_CLIENT_SECRET}",
|
|
14
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
|
@@ -30860,36 +30860,49 @@ var StdioServerTransport = class {
|
|
|
30860
30860
|
};
|
|
30861
30861
|
|
|
30862
30862
|
// src/config.ts
|
|
30863
|
+
function readVar(env, key) {
|
|
30864
|
+
const raw = env[key];
|
|
30865
|
+
if (typeof raw !== "string") return void 0;
|
|
30866
|
+
const trimmed = raw.trim();
|
|
30867
|
+
if (trimmed.length === 0) return void 0;
|
|
30868
|
+
if (/^\$\{[^}]*\}$/.test(trimmed)) return void 0;
|
|
30869
|
+
return trimmed;
|
|
30870
|
+
}
|
|
30863
30871
|
function loadAccount(env = process.env) {
|
|
30864
|
-
const baseUrl = env
|
|
30872
|
+
const baseUrl = readVar(env, "CANVAS_BASE_URL");
|
|
30865
30873
|
if (!baseUrl) {
|
|
30866
30874
|
throw new Error(
|
|
30867
|
-
"Missing required env var: CANVAS_BASE_URL. Set CANVAS_BASE_URL (e.g. https://cms.instructure.com) plus
|
|
30875
|
+
"Missing required env var: CANVAS_BASE_URL. Set CANVAS_BASE_URL (e.g. https://cms.instructure.com) plus an auth mode."
|
|
30868
30876
|
);
|
|
30869
30877
|
}
|
|
30870
30878
|
if (!/^https:\/\//.test(baseUrl)) {
|
|
30871
30879
|
throw new Error(`CANVAS_BASE_URL must be an https URL, got: '${baseUrl}'`);
|
|
30872
30880
|
}
|
|
30873
30881
|
const cleanBaseUrl = baseUrl.replace(/\/$/, "");
|
|
30874
|
-
const name = env
|
|
30875
|
-
const token = env
|
|
30876
|
-
const
|
|
30877
|
-
const
|
|
30878
|
-
const
|
|
30879
|
-
const
|
|
30880
|
-
const
|
|
30881
|
-
const refreshToken = env.CANVAS_REFRESH_TOKEN;
|
|
30882
|
+
const name = readVar(env, "CANVAS_NAME") ?? new URL(cleanBaseUrl).host;
|
|
30883
|
+
const token = readVar(env, "CANVAS_TOKEN");
|
|
30884
|
+
const username = readVar(env, "CANVAS_USERNAME");
|
|
30885
|
+
const password = readVar(env, "CANVAS_PASSWORD");
|
|
30886
|
+
const clientId = readVar(env, "CANVAS_CLIENT_ID");
|
|
30887
|
+
const clientSecret = readVar(env, "CANVAS_CLIENT_SECRET");
|
|
30888
|
+
const refreshToken = readVar(env, "CANVAS_REFRESH_TOKEN");
|
|
30882
30889
|
const oauthSet = [clientId, clientSecret, refreshToken].filter((v) => !!v);
|
|
30883
30890
|
const hasFullOAuth = oauthSet.length === 3;
|
|
30884
30891
|
const hasPartialOAuth = oauthSet.length > 0 && oauthSet.length < 3;
|
|
30885
30892
|
const hasFullUserPass = !!username && !!password;
|
|
30886
30893
|
const hasPartialUserPass = !!username !== !!password;
|
|
30887
|
-
const hasSession = !!cookie || hasFullUserPass;
|
|
30888
30894
|
if (token) {
|
|
30889
|
-
const others = [
|
|
30890
|
-
if (others)
|
|
30895
|
+
const others = [];
|
|
30896
|
+
if (hasFullOAuth) others.push("OAuth env vars");
|
|
30897
|
+
else if (hasPartialOAuth) others.push("partial OAuth env vars (CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN)");
|
|
30898
|
+
if (hasFullUserPass) others.push("username/password env vars");
|
|
30899
|
+
else if (hasPartialUserPass) {
|
|
30900
|
+
others.push(`partial username/password (only ${username ? "CANVAS_USERNAME" : "CANVAS_PASSWORD"} set)`);
|
|
30901
|
+
}
|
|
30902
|
+
if (others.length > 0) {
|
|
30903
|
+
const joined = others.join(" and ");
|
|
30891
30904
|
console.error(
|
|
30892
|
-
`[canvas-mcp] CANVAS_TOKEN takes precedence over ${
|
|
30905
|
+
`[canvas-parent-mcp] CANVAS_TOKEN takes precedence over ${joined} \u2014 using CANVAS_TOKEN. Unset CANVAS_TOKEN to use ${joined}.`
|
|
30893
30906
|
);
|
|
30894
30907
|
}
|
|
30895
30908
|
return { mode: "token", name, baseUrl: cleanBaseUrl, token };
|
|
@@ -30900,19 +30913,13 @@ function loadAccount(env = process.env) {
|
|
|
30900
30913
|
`Incomplete username/password config \u2014 missing: ${missing}. Set both CANVAS_USERNAME and CANVAS_PASSWORD, or unset both.`
|
|
30901
30914
|
);
|
|
30902
30915
|
}
|
|
30903
|
-
if (
|
|
30916
|
+
if (hasFullUserPass) {
|
|
30904
30917
|
if (hasFullOAuth) {
|
|
30905
30918
|
console.error(
|
|
30906
|
-
"[canvas-mcp]
|
|
30919
|
+
"[canvas-parent-mcp] CANVAS_USERNAME+CANVAS_PASSWORD takes precedence over OAuth \u2014 using username/password. Unset them to use OAuth."
|
|
30907
30920
|
);
|
|
30908
30921
|
}
|
|
30909
|
-
|
|
30910
|
-
if (cookie) acct.cookie = cookie;
|
|
30911
|
-
if (hasFullUserPass) {
|
|
30912
|
-
acct.username = username;
|
|
30913
|
-
acct.password = password;
|
|
30914
|
-
}
|
|
30915
|
-
return acct;
|
|
30922
|
+
return { mode: "session", name, baseUrl: cleanBaseUrl, username, password };
|
|
30916
30923
|
}
|
|
30917
30924
|
if (hasPartialOAuth) {
|
|
30918
30925
|
const missing = [];
|
|
@@ -30920,7 +30927,7 @@ function loadAccount(env = process.env) {
|
|
|
30920
30927
|
if (!clientSecret) missing.push("CANVAS_CLIENT_SECRET");
|
|
30921
30928
|
if (!refreshToken) missing.push("CANVAS_REFRESH_TOKEN");
|
|
30922
30929
|
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 /
|
|
30930
|
+
`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
30931
|
);
|
|
30925
30932
|
}
|
|
30926
30933
|
if (hasFullOAuth) {
|
|
@@ -30931,11 +30938,11 @@ function loadAccount(env = process.env) {
|
|
|
30931
30938
|
clientId,
|
|
30932
30939
|
clientSecret,
|
|
30933
30940
|
refreshToken,
|
|
30934
|
-
accessToken: env
|
|
30941
|
+
accessToken: readVar(env, "CANVAS_ACCESS_TOKEN")
|
|
30935
30942
|
};
|
|
30936
30943
|
}
|
|
30937
30944
|
throw new Error(
|
|
30938
|
-
"Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), CANVAS_USERNAME+CANVAS_PASSWORD (auto-login),
|
|
30945
|
+
"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
30946
|
);
|
|
30940
30947
|
}
|
|
30941
30948
|
|
|
@@ -31041,10 +31048,7 @@ async function sessionLogin(opts) {
|
|
|
31041
31048
|
const sessionCookies = respCookies.filter(
|
|
31042
31049
|
(c) => ["canvas_session", "pseudonym_credentials", "_csrf_token", "log_session_id"].includes(c.name)
|
|
31043
31050
|
);
|
|
31044
|
-
return {
|
|
31045
|
-
baseUrl,
|
|
31046
|
-
cookie: serializeCookies(sessionCookies)
|
|
31047
|
-
};
|
|
31051
|
+
return { cookie: serializeCookies(sessionCookies) };
|
|
31048
31052
|
}
|
|
31049
31053
|
|
|
31050
31054
|
// src/client.ts
|
|
@@ -31054,6 +31058,7 @@ var CanvasClient = class {
|
|
|
31054
31058
|
account;
|
|
31055
31059
|
refreshInFlight = null;
|
|
31056
31060
|
accessTokenExpiresAt = 0;
|
|
31061
|
+
sessionCookie = null;
|
|
31057
31062
|
sessionLoginFn;
|
|
31058
31063
|
constructor(account2, opts = {}) {
|
|
31059
31064
|
this.account = account2;
|
|
@@ -31064,7 +31069,7 @@ var CanvasClient = class {
|
|
|
31064
31069
|
return { name: this.account.name, baseUrl: this.account.baseUrl, mode: this.account.mode };
|
|
31065
31070
|
}
|
|
31066
31071
|
async request(path, opts = {}) {
|
|
31067
|
-
const res = await this.doRawRequest(path, opts
|
|
31072
|
+
const res = await this.doRawRequest(path, opts);
|
|
31068
31073
|
const text = await res.text();
|
|
31069
31074
|
if (opts.responseType === "text") return text;
|
|
31070
31075
|
return parseJsonBody(text) ?? null;
|
|
@@ -31075,7 +31080,7 @@ var CanvasClient = class {
|
|
|
31075
31080
|
let url2 = injectPerPage(path, perPage);
|
|
31076
31081
|
const out = [];
|
|
31077
31082
|
for (let page = 0; page < maxPages; page++) {
|
|
31078
|
-
const res = await this.doRawRequest(url2, opts
|
|
31083
|
+
const res = await this.doRawRequest(url2, opts);
|
|
31079
31084
|
const text = await res.text();
|
|
31080
31085
|
const parsed = parseJsonBody(text) ?? [];
|
|
31081
31086
|
for (const item of parsed) out.push(item);
|
|
@@ -31100,11 +31105,8 @@ var CanvasClient = class {
|
|
|
31100
31105
|
} catch {
|
|
31101
31106
|
throw new ParentDirectoryMissingError(parent);
|
|
31102
31107
|
}
|
|
31103
|
-
await this.ensureAuth();
|
|
31104
31108
|
const url2 = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
|
|
31105
|
-
const res = await
|
|
31106
|
-
headers: this.getAuthHeaders()
|
|
31107
|
-
});
|
|
31109
|
+
const res = await this.authedFetch(url2, {});
|
|
31108
31110
|
if (res.status === 404) throw new Error(`Canvas download 404 for ${path}`);
|
|
31109
31111
|
if (!res.ok) throw new Error(`Canvas download ${res.status} for ${path}`);
|
|
31110
31112
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
@@ -31115,42 +31117,43 @@ var CanvasClient = class {
|
|
|
31115
31117
|
contentType: res.headers.get("content-type") ?? "application/octet-stream"
|
|
31116
31118
|
};
|
|
31117
31119
|
}
|
|
31118
|
-
async doRawRequest(path, opts
|
|
31119
|
-
await this.ensureAuth();
|
|
31120
|
+
async doRawRequest(path, opts) {
|
|
31120
31121
|
const url2 = /^https?:\/\//i.test(path) ? path : `${this.account.baseUrl}${path}`;
|
|
31121
31122
|
const accept = opts.responseType === "text" ? "text/html, text/plain, */*" : "application/json+canvas-string-ids, application/json";
|
|
31122
|
-
const res = await
|
|
31123
|
+
const res = await this.authedFetch(url2, {
|
|
31123
31124
|
method: opts.method ?? "GET",
|
|
31124
|
-
headers: {
|
|
31125
|
-
...this.getAuthHeaders(),
|
|
31126
|
-
Accept: accept,
|
|
31127
|
-
...opts.headers ?? {}
|
|
31128
|
-
},
|
|
31125
|
+
headers: { Accept: accept, ...opts.headers ?? {} },
|
|
31129
31126
|
body: opts.body
|
|
31130
31127
|
});
|
|
31128
|
+
if (res.status === 404) throw new Error(`Canvas 404 ${path}`);
|
|
31129
|
+
if (res.status >= 500) throw new CanvasUnreachableError(res.status);
|
|
31130
|
+
if (!res.ok) throw new Error(`Canvas ${res.status} ${res.statusText} for ${path}`);
|
|
31131
|
+
return res;
|
|
31132
|
+
}
|
|
31133
|
+
/**
|
|
31134
|
+
* Fetch with auth headers attached and transparent 401 retry. Token mode has
|
|
31135
|
+
* no refresh path so a 401 throws immediately; oauth and session both attempt
|
|
31136
|
+
* one re-auth before giving up. Used by both API requests and file downloads.
|
|
31137
|
+
*/
|
|
31138
|
+
async authedFetch(url2, init, isRetry = false) {
|
|
31139
|
+
await this.ensureAuth();
|
|
31140
|
+
const res = await fetch(url2, {
|
|
31141
|
+
...init,
|
|
31142
|
+
headers: { ...this.getAuthHeaders(), ...init.headers }
|
|
31143
|
+
});
|
|
31131
31144
|
if (res.status === 401) {
|
|
31132
|
-
if (isRetry ||
|
|
31145
|
+
if (isRetry || this.account.mode === "token") {
|
|
31133
31146
|
throw new TokenExpiredError(this.account.mode);
|
|
31134
31147
|
}
|
|
31135
31148
|
await this.ensureAuth({ force: true });
|
|
31136
|
-
return this.
|
|
31149
|
+
return this.authedFetch(url2, init, true);
|
|
31137
31150
|
}
|
|
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
31151
|
return res;
|
|
31142
31152
|
}
|
|
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
31153
|
getAuthHeaders() {
|
|
31151
31154
|
const acct = this.account;
|
|
31152
31155
|
if (acct.mode === "token") return { Authorization: `Bearer ${acct.token}` };
|
|
31153
|
-
if (acct.mode === "session") return { Cookie:
|
|
31156
|
+
if (acct.mode === "session") return { Cookie: this.sessionCookie };
|
|
31154
31157
|
return { Authorization: `Bearer ${acct.accessToken}` };
|
|
31155
31158
|
}
|
|
31156
31159
|
async ensureAuth(opts = {}) {
|
|
@@ -31158,10 +31161,7 @@ var CanvasClient = class {
|
|
|
31158
31161
|
if (acct.mode === "token") return;
|
|
31159
31162
|
if (!opts.force) {
|
|
31160
31163
|
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");
|
|
31164
|
+
if (acct.mode === "session" && this.sessionCookie) return;
|
|
31165
31165
|
}
|
|
31166
31166
|
if (this.refreshInFlight) {
|
|
31167
31167
|
await this.refreshInFlight;
|
|
@@ -31180,7 +31180,7 @@ var CanvasClient = class {
|
|
|
31180
31180
|
username: acct.username,
|
|
31181
31181
|
password: acct.password
|
|
31182
31182
|
});
|
|
31183
|
-
|
|
31183
|
+
this.sessionCookie = result.cookie;
|
|
31184
31184
|
}
|
|
31185
31185
|
async refreshAccessToken(acct) {
|
|
31186
31186
|
const body = new URLSearchParams({
|
|
@@ -31224,7 +31224,7 @@ function parseLinkHeader(header) {
|
|
|
31224
31224
|
}
|
|
31225
31225
|
var TokenExpiredError = class extends Error {
|
|
31226
31226
|
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
|
|
31227
|
+
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
31228
|
super(detail ? `${base} (${detail})` : base);
|
|
31229
31229
|
this.mode = mode;
|
|
31230
31230
|
this.detail = detail;
|
|
@@ -31642,7 +31642,7 @@ try {
|
|
|
31642
31642
|
}
|
|
31643
31643
|
var account = loadAccount();
|
|
31644
31644
|
var client = new CanvasClient(account);
|
|
31645
|
-
var server = new McpServer({ name: "canvas", version: "0.1
|
|
31645
|
+
var server = new McpServer({ name: "canvas", version: "1.0.1" });
|
|
31646
31646
|
registerProfileTools(server, client);
|
|
31647
31647
|
registerObserveeTools(server, client);
|
|
31648
31648
|
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
|
@@ -1,34 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read an env var, trim whitespace, and treat the empty string as unset. Also
|
|
3
|
+
* defensively rejects values that look like an unsubstituted shell placeholder
|
|
4
|
+
* (e.g. "${CANVAS_TOKEN}") — these can leak through .mcp.json env blocks when
|
|
5
|
+
* the host (Claude Code / Desktop) doesn't expand them, and would otherwise
|
|
6
|
+
* be sent to Canvas as a literal token and rejected with a confusing 401.
|
|
7
|
+
*/
|
|
8
|
+
function readVar(env, key) {
|
|
9
|
+
const raw = env[key];
|
|
10
|
+
if (typeof raw !== 'string')
|
|
11
|
+
return undefined;
|
|
12
|
+
const trimmed = raw.trim();
|
|
13
|
+
if (trimmed.length === 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (/^\$\{[^}]*\}$/.test(trimmed))
|
|
16
|
+
return undefined;
|
|
17
|
+
return trimmed;
|
|
18
|
+
}
|
|
1
19
|
export function loadAccount(env = process.env) {
|
|
2
|
-
const baseUrl = env
|
|
20
|
+
const baseUrl = readVar(env, 'CANVAS_BASE_URL');
|
|
3
21
|
if (!baseUrl) {
|
|
4
22
|
throw new Error('Missing required env var: CANVAS_BASE_URL. ' +
|
|
5
|
-
'Set CANVAS_BASE_URL (e.g. https://cms.instructure.com) plus
|
|
23
|
+
'Set CANVAS_BASE_URL (e.g. https://cms.instructure.com) plus an auth mode.');
|
|
6
24
|
}
|
|
7
25
|
if (!/^https:\/\//.test(baseUrl)) {
|
|
8
26
|
throw new Error(`CANVAS_BASE_URL must be an https URL, got: '${baseUrl}'`);
|
|
9
27
|
}
|
|
10
28
|
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
|
11
|
-
const name = env
|
|
12
|
-
const token = env
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const refreshToken = env.CANVAS_REFRESH_TOKEN;
|
|
29
|
+
const name = readVar(env, 'CANVAS_NAME') ?? new URL(cleanBaseUrl).host;
|
|
30
|
+
const token = readVar(env, 'CANVAS_TOKEN');
|
|
31
|
+
const username = readVar(env, 'CANVAS_USERNAME');
|
|
32
|
+
const password = readVar(env, 'CANVAS_PASSWORD');
|
|
33
|
+
const clientId = readVar(env, 'CANVAS_CLIENT_ID');
|
|
34
|
+
const clientSecret = readVar(env, 'CANVAS_CLIENT_SECRET');
|
|
35
|
+
const refreshToken = readVar(env, 'CANVAS_REFRESH_TOKEN');
|
|
19
36
|
const oauthSet = [clientId, clientSecret, refreshToken].filter((v) => !!v);
|
|
20
37
|
const hasFullOAuth = oauthSet.length === 3;
|
|
21
38
|
const hasPartialOAuth = oauthSet.length > 0 && oauthSet.length < 3;
|
|
22
39
|
const hasFullUserPass = !!username && !!password;
|
|
23
40
|
const hasPartialUserPass = (!!username) !== (!!password);
|
|
24
|
-
const hasSession = !!cookie || hasFullUserPass;
|
|
25
41
|
if (token) {
|
|
26
|
-
const others = [
|
|
27
|
-
|
|
28
|
-
.
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
const others = [];
|
|
43
|
+
if (hasFullOAuth)
|
|
44
|
+
others.push('OAuth env vars');
|
|
45
|
+
else if (hasPartialOAuth)
|
|
46
|
+
others.push('partial OAuth env vars (CANVAS_CLIENT_*/CANVAS_REFRESH_TOKEN)');
|
|
47
|
+
if (hasFullUserPass)
|
|
48
|
+
others.push('username/password env vars');
|
|
49
|
+
else if (hasPartialUserPass) {
|
|
50
|
+
others.push(`partial username/password (only ${username ? 'CANVAS_USERNAME' : 'CANVAS_PASSWORD'} set)`);
|
|
51
|
+
}
|
|
52
|
+
if (others.length > 0) {
|
|
53
|
+
const joined = others.join(' and ');
|
|
54
|
+
console.error(`[canvas-parent-mcp] CANVAS_TOKEN takes precedence over ${joined} — using CANVAS_TOKEN. ` +
|
|
55
|
+
`Unset CANVAS_TOKEN to use ${joined}.`);
|
|
32
56
|
}
|
|
33
57
|
return { mode: 'token', name, baseUrl: cleanBaseUrl, token };
|
|
34
58
|
}
|
|
@@ -37,19 +61,12 @@ export function loadAccount(env = process.env) {
|
|
|
37
61
|
throw new Error(`Incomplete username/password config — missing: ${missing}. ` +
|
|
38
62
|
'Set both CANVAS_USERNAME and CANVAS_PASSWORD, or unset both.');
|
|
39
63
|
}
|
|
40
|
-
if (
|
|
64
|
+
if (hasFullUserPass) {
|
|
41
65
|
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;
|
|
66
|
+
console.error('[canvas-parent-mcp] CANVAS_USERNAME+CANVAS_PASSWORD takes precedence over OAuth — using ' +
|
|
67
|
+
'username/password. Unset them to use OAuth.');
|
|
51
68
|
}
|
|
52
|
-
return
|
|
69
|
+
return { mode: 'session', name, baseUrl: cleanBaseUrl, username: username, password: password };
|
|
53
70
|
}
|
|
54
71
|
if (hasPartialOAuth) {
|
|
55
72
|
const missing = [];
|
|
@@ -61,7 +78,7 @@ export function loadAccount(env = process.env) {
|
|
|
61
78
|
missing.push('CANVAS_REFRESH_TOKEN');
|
|
62
79
|
throw new Error(`Incomplete OAuth config — missing: ${missing.join(', ')}. ` +
|
|
63
80
|
'Set all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN ' +
|
|
64
|
-
'(or set CANVAS_TOKEN /
|
|
81
|
+
'(or set CANVAS_TOKEN / CANVAS_USERNAME+CANVAS_PASSWORD instead).');
|
|
65
82
|
}
|
|
66
83
|
if (hasFullOAuth) {
|
|
67
84
|
return {
|
|
@@ -71,11 +88,10 @@ export function loadAccount(env = process.env) {
|
|
|
71
88
|
clientId: clientId,
|
|
72
89
|
clientSecret: clientSecret,
|
|
73
90
|
refreshToken: refreshToken,
|
|
74
|
-
accessToken: env
|
|
91
|
+
accessToken: readVar(env, 'CANVAS_ACCESS_TOKEN'),
|
|
75
92
|
};
|
|
76
93
|
}
|
|
77
94
|
throw new Error('Missing Canvas auth config. Set one of: CANVAS_TOKEN (personal access token), ' +
|
|
78
95
|
'CANVAS_USERNAME+CANVAS_PASSWORD (auto-login), ' +
|
|
79
|
-
'CANVAS_COOKIE (session cookie jar from canvas-parent-mcp-login), ' +
|
|
80
96
|
'or all three of CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REFRESH_TOKEN.');
|
|
81
97
|
}
|
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: '1.0.1' });
|
|
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.1
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"mcpName": "io.github.chrischall/canvas-parent-mcp",
|
|
5
5
|
"description": "Canvas LMS (Instructure) MCP server — student/observer read access with optional file download",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
"type": "module",
|
|
28
28
|
"bin": {
|
|
29
29
|
"canvas-parent-mcp": "dist/index.js",
|
|
30
|
-
"canvas-parent-mcp-qr-login": "dist/qr-login-cli.js"
|
|
31
|
-
"canvas-parent-mcp-login": "dist/session-login-cli.js"
|
|
30
|
+
"canvas-parent-mcp-qr-login": "dist/qr-login-cli.js"
|
|
32
31
|
},
|
|
33
32
|
"files": [
|
|
34
33
|
"dist",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/canvas-parent-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.1
|
|
9
|
+
"version": "1.0.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "canvas-parent-mcp",
|
|
14
|
-
"version": "0.1
|
|
14
|
+
"version": "1.0.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -22,49 +22,42 @@
|
|
|
22
22
|
"isRequired": true,
|
|
23
23
|
"format": "string"
|
|
24
24
|
},
|
|
25
|
-
{
|
|
26
|
-
"name": "CANVAS_TOKEN",
|
|
27
|
-
"description": "Canvas personal access token (pick ONE auth mode: this, USERNAME+PASSWORD, COOKIE, or OAuth triple)",
|
|
28
|
-
"isRequired": false,
|
|
29
|
-
"format": "string",
|
|
30
|
-
"isSecret": true
|
|
31
|
-
},
|
|
32
25
|
{
|
|
33
26
|
"name": "CANVAS_USERNAME",
|
|
34
|
-
"description": "Canvas login username/email — pair with CANVAS_PASSWORD for auto-login
|
|
27
|
+
"description": "Canvas login username/email (recommended) — pair with CANVAS_PASSWORD for auto-login. No SSO/2FA.",
|
|
35
28
|
"isRequired": false,
|
|
36
29
|
"format": "string"
|
|
37
30
|
},
|
|
38
31
|
{
|
|
39
32
|
"name": "CANVAS_PASSWORD",
|
|
40
|
-
"description": "Canvas password
|
|
33
|
+
"description": "Canvas password (recommended) — required iff CANVAS_USERNAME is set",
|
|
41
34
|
"isRequired": false,
|
|
42
35
|
"format": "string",
|
|
43
36
|
"isSecret": true
|
|
44
37
|
},
|
|
45
38
|
{
|
|
46
|
-
"name": "
|
|
47
|
-
"description": "
|
|
39
|
+
"name": "CANVAS_TOKEN",
|
|
40
|
+
"description": "Canvas personal access token (advanced) — most schools have disabled token creation",
|
|
48
41
|
"isRequired": false,
|
|
49
42
|
"format": "string",
|
|
50
43
|
"isSecret": true
|
|
51
44
|
},
|
|
52
45
|
{
|
|
53
46
|
"name": "CANVAS_CLIENT_ID",
|
|
54
|
-
"description": "Canvas OAuth client ID (
|
|
47
|
+
"description": "Canvas OAuth client ID (advanced — for SSO accounts that can't use username/password)",
|
|
55
48
|
"isRequired": false,
|
|
56
49
|
"format": "string"
|
|
57
50
|
},
|
|
58
51
|
{
|
|
59
52
|
"name": "CANVAS_CLIENT_SECRET",
|
|
60
|
-
"description": "Canvas OAuth client secret (
|
|
53
|
+
"description": "Canvas OAuth client secret (advanced)",
|
|
61
54
|
"isRequired": false,
|
|
62
55
|
"format": "string",
|
|
63
56
|
"isSecret": true
|
|
64
57
|
},
|
|
65
58
|
{
|
|
66
59
|
"name": "CANVAS_REFRESH_TOKEN",
|
|
67
|
-
"description": "Canvas OAuth refresh token (
|
|
60
|
+
"description": "Canvas OAuth refresh token (advanced) — bootstrap via canvas-parent-mcp-qr-login",
|
|
68
61
|
"isRequired": false,
|
|
69
62
|
"format": "string",
|
|
70
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,40 +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
|
-
"command": "npx",
|
|
44
|
-
"args": ["-y", "canvas-parent-mcp"],
|
|
45
|
-
"env": {
|
|
46
|
-
"CANVAS_BASE_URL": "https://cms.instructure.com",
|
|
47
|
-
"CANVAS_USERNAME": "me@example.com",
|
|
48
|
-
"CANVAS_PASSWORD": "your-canvas-password"
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
45
|
+
"env": {
|
|
46
|
+
"CANVAS_BASE_URL": "https://cms.instructure.com",
|
|
47
|
+
"CANVAS_TOKEN": "your-personal-access-token"
|
|
52
48
|
}
|
|
53
49
|
```
|
|
54
50
|
|
|
55
|
-
|
|
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:
|
|
56
52
|
|
|
57
53
|
```json
|
|
58
|
-
{
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"env": {
|
|
64
|
-
"CANVAS_BASE_URL": "https://cms.instructure.com",
|
|
65
|
-
"CANVAS_CLIENT_ID": "...",
|
|
66
|
-
"CANVAS_CLIENT_SECRET": "...",
|
|
67
|
-
"CANVAS_REFRESH_TOKEN": "..."
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
54
|
+
"env": {
|
|
55
|
+
"CANVAS_BASE_URL": "https://cms.instructure.com",
|
|
56
|
+
"CANVAS_CLIENT_ID": "...",
|
|
57
|
+
"CANVAS_CLIENT_SECRET": "...",
|
|
58
|
+
"CANVAS_REFRESH_TOKEN": "..."
|
|
71
59
|
}
|
|
72
60
|
```
|
|
73
61
|
|
|
@@ -90,7 +78,7 @@ npm install && npm run build
|
|
|
90
78
|
|
|
91
79
|
```bash
|
|
92
80
|
cp .env.example .env
|
|
93
|
-
# Edit .env: set CANVAS_BASE_URL plus one of CANVAS_TOKEN, CANVAS_USERNAME+CANVAS_PASSWORD,
|
|
81
|
+
# Edit .env: set CANVAS_BASE_URL plus one of CANVAS_TOKEN, CANVAS_USERNAME+CANVAS_PASSWORD, or the OAuth triple.
|
|
94
82
|
```
|
|
95
83
|
|
|
96
84
|
#### 3. Register with mcporter
|
|
@@ -99,11 +87,14 @@ cp .env.example .env
|
|
|
99
87
|
mcporter config add canvas \
|
|
100
88
|
--command "canvas-parent-mcp" \
|
|
101
89
|
--env "CANVAS_BASE_URL=https://cms.instructure.com" \
|
|
102
|
-
--env "
|
|
90
|
+
--env "CANVAS_USERNAME=me@example.com" \
|
|
91
|
+
--env "CANVAS_PASSWORD=your-canvas-password" \
|
|
103
92
|
--env "CANVAS_NAME=CMS" \
|
|
104
93
|
--config ~/.mcporter/mcporter.json
|
|
105
94
|
```
|
|
106
95
|
|
|
96
|
+
(Or substitute `CANVAS_TOKEN` / OAuth env vars per the alternatives above.)
|
|
97
|
+
|
|
107
98
|
#### 4. Verify
|
|
108
99
|
|
|
109
100
|
```bash
|
|
@@ -207,5 +198,5 @@ Most tools accept an optional `observeeId` (defaults to `self`) — set it to a
|
|
|
207
198
|
## Caution
|
|
208
199
|
|
|
209
200
|
- `canvas_download_file` writes to disk at `destinationPath` — confirm the path with the user; pass `overwrite:true` to replace.
|
|
210
|
-
-
|
|
201
|
+
- All auth credentials (password, token, refresh token) grant the same access as the user account. Treat them as secrets — never commit `.env`.
|
|
211
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));
|