canvas-parent-mcp 1.4.0 → 1.5.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/dist/bundle.js +151 -3
- package/dist/index.js +5 -1
- package/dist/tools/healthcheck.js +47 -0
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/canvas-parent/SKILL.md +1 -0
|
@@ -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": "1.
|
|
10
|
+
"version": "1.5.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": "1.
|
|
18
|
+
"version": "1.5.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": "1.
|
|
4
|
+
"version": "1.5.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/dist/bundle.js
CHANGED
|
@@ -34731,6 +34731,11 @@ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
|
|
|
34731
34731
|
return redacted;
|
|
34732
34732
|
return `${redacted.slice(0, max)}\u2026 [truncated]`;
|
|
34733
34733
|
}
|
|
34734
|
+
function messageOf(err) {
|
|
34735
|
+
if (err instanceof Error)
|
|
34736
|
+
return err.message;
|
|
34737
|
+
return String(err);
|
|
34738
|
+
}
|
|
34734
34739
|
|
|
34735
34740
|
// node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
34736
34741
|
function textResult(data) {
|
|
@@ -39237,7 +39242,7 @@ function loadAccount(env = process.env) {
|
|
|
39237
39242
|
// package.json
|
|
39238
39243
|
var package_default = {
|
|
39239
39244
|
name: "canvas-parent-mcp",
|
|
39240
|
-
version: "1.
|
|
39245
|
+
version: "1.5.0",
|
|
39241
39246
|
mcpName: "io.github.chrischall/canvas-parent-mcp",
|
|
39242
39247
|
description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
|
|
39243
39248
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -39284,7 +39289,7 @@ var package_default = {
|
|
|
39284
39289
|
typecheck: "tsc -p tsconfig.json --noEmit"
|
|
39285
39290
|
},
|
|
39286
39291
|
dependencies: {
|
|
39287
|
-
"@chrischall/mcp-utils": "^0.
|
|
39292
|
+
"@chrischall/mcp-utils": "^0.19.2",
|
|
39288
39293
|
"@fetchproxy/bootstrap": "^2.2.0",
|
|
39289
39294
|
"@fetchproxy/server": "^2.2.0",
|
|
39290
39295
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -39392,6 +39397,118 @@ async function liftBrowserCookie(declaredDomain, baseHost) {
|
|
|
39392
39397
|
}
|
|
39393
39398
|
}
|
|
39394
39399
|
|
|
39400
|
+
// node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
|
|
39401
|
+
function statusOf(err) {
|
|
39402
|
+
if (typeof err !== "object" || err === null)
|
|
39403
|
+
return void 0;
|
|
39404
|
+
const s = err.status ?? err.statusCode;
|
|
39405
|
+
return typeof s === "number" ? s : void 0;
|
|
39406
|
+
}
|
|
39407
|
+
function credentialHint(arm, prefix, hostLabel, source) {
|
|
39408
|
+
switch (arm) {
|
|
39409
|
+
case "ok":
|
|
39410
|
+
return `Credential from '${source}' works: ${hostLabel} accepted an authenticated request. If a real tool still fails, the problem is that tool, not auth.`;
|
|
39411
|
+
case "no_credential":
|
|
39412
|
+
return `No credential resolved. Nothing was available to authenticate with \u2014 sign in and reconnect the connector so ${prefix} receives a token, or set the documented environment variable.`;
|
|
39413
|
+
case "credential_rejected":
|
|
39414
|
+
return `${hostLabel} rejected the credential from '${source}'. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
|
|
39415
|
+
case "timeout":
|
|
39416
|
+
return `The credential from '${source}' resolved, but ${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
|
|
39417
|
+
case "http":
|
|
39418
|
+
return `${hostLabel} answered with an error status that is not an auth rejection. That is USUALLY a ${hostLabel}-side problem rather than an auth one \u2014 but a 404 here more often means the probe path is wrong than that ${hostLabel} is broken, so check error.message and probe.url before concluding anything about the credential.`;
|
|
39419
|
+
case "transport":
|
|
39420
|
+
return `Could not reach ${hostLabel} at all. Check network egress; the credential itself was never judged.`;
|
|
39421
|
+
default:
|
|
39422
|
+
return `Unexpected failure \u2014 see error.message.`;
|
|
39423
|
+
}
|
|
39424
|
+
}
|
|
39425
|
+
function registerCredentialHealthcheckTool(args) {
|
|
39426
|
+
const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
|
|
39427
|
+
const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
|
|
39428
|
+
server.registerTool(`${prefix}_healthcheck`, {
|
|
39429
|
+
title: "Verify credentials and upstream reachability",
|
|
39430
|
+
description: `Resolves the credential the way real tools do, then makes one authenticated request to ${hostLabel}. Reports which source supplied the credential, whether ${hostLabel} accepted it, the round-trip time, and a plain-English hint distinguishing 'no credential' from 'credential rejected' from 'a ${hostLabel}-side problem'. Call this when a real tool fails and you want to know which hop broke. Read-only; never returns the credential itself.`,
|
|
39431
|
+
annotations: {
|
|
39432
|
+
title: "Verify credentials and upstream reachability",
|
|
39433
|
+
readOnlyHint: true,
|
|
39434
|
+
idempotentHint: true,
|
|
39435
|
+
openWorldHint: true
|
|
39436
|
+
},
|
|
39437
|
+
inputSchema: {}
|
|
39438
|
+
}, async () => {
|
|
39439
|
+
let probeStarted = 0;
|
|
39440
|
+
let state;
|
|
39441
|
+
try {
|
|
39442
|
+
state = await resolveCredential();
|
|
39443
|
+
} catch (e) {
|
|
39444
|
+
const result2 = {
|
|
39445
|
+
ok: false,
|
|
39446
|
+
credential: { source: null, resolved: false },
|
|
39447
|
+
// No `url`: nothing was probed, and naming one implies it was tried.
|
|
39448
|
+
probe: { elapsed_ms: 0 },
|
|
39449
|
+
error: { kind: "no_credential", message: truncateErrorMessage(messageOf(e)) },
|
|
39450
|
+
hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
|
|
39451
|
+
};
|
|
39452
|
+
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
39453
|
+
}
|
|
39454
|
+
const credential = {
|
|
39455
|
+
source: state.source,
|
|
39456
|
+
resolved: state.source !== null,
|
|
39457
|
+
...state.detail !== void 0 ? { detail: state.detail } : {}
|
|
39458
|
+
};
|
|
39459
|
+
if (!credential.resolved) {
|
|
39460
|
+
const result2 = {
|
|
39461
|
+
ok: false,
|
|
39462
|
+
credential,
|
|
39463
|
+
probe: { elapsed_ms: 0 },
|
|
39464
|
+
error: { kind: "no_credential", message: "no credential source resolved" },
|
|
39465
|
+
hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
|
|
39466
|
+
};
|
|
39467
|
+
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
39468
|
+
}
|
|
39469
|
+
let arm = "ok";
|
|
39470
|
+
let error51;
|
|
39471
|
+
let status;
|
|
39472
|
+
let customHint;
|
|
39473
|
+
probeStarted = Date.now();
|
|
39474
|
+
try {
|
|
39475
|
+
await probeFn();
|
|
39476
|
+
} catch (e) {
|
|
39477
|
+
status = statusOf(e);
|
|
39478
|
+
const aborted2 = e instanceof Error && e.name === "AbortError";
|
|
39479
|
+
arm = status === 401 || status === 403 ? "credential_rejected" : status !== void 0 ? "http" : aborted2 || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e)) ? "timeout" : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e)) ? "transport" : "unknown";
|
|
39480
|
+
let kind = arm;
|
|
39481
|
+
let detail;
|
|
39482
|
+
const custom2 = classifyThrown?.(e);
|
|
39483
|
+
if (custom2) {
|
|
39484
|
+
kind = custom2.kind;
|
|
39485
|
+
customHint = custom2.hint;
|
|
39486
|
+
detail = custom2.detail;
|
|
39487
|
+
}
|
|
39488
|
+
error51 = {
|
|
39489
|
+
kind,
|
|
39490
|
+
// Redacted AND bounded before it reaches the result: an upstream
|
|
39491
|
+
// failure routinely quotes what it was sent, and a healthcheck is
|
|
39492
|
+
// the tool people paste into a chat when something is broken.
|
|
39493
|
+
message: truncateErrorMessage(messageOf(e)),
|
|
39494
|
+
...detail !== void 0 ? { detail } : {}
|
|
39495
|
+
};
|
|
39496
|
+
}
|
|
39497
|
+
const result = {
|
|
39498
|
+
ok: error51 === void 0,
|
|
39499
|
+
credential,
|
|
39500
|
+
probe: {
|
|
39501
|
+
...probeUrl ? { url: probeUrl } : {},
|
|
39502
|
+
elapsed_ms: Date.now() - probeStarted,
|
|
39503
|
+
...status !== void 0 ? { status } : {}
|
|
39504
|
+
},
|
|
39505
|
+
...error51 ? { error: error51 } : {},
|
|
39506
|
+
hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
|
|
39507
|
+
};
|
|
39508
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
39509
|
+
});
|
|
39510
|
+
}
|
|
39511
|
+
|
|
39395
39512
|
// src/client.ts
|
|
39396
39513
|
import { writeFile as writeFile3, stat } from "fs/promises";
|
|
39397
39514
|
|
|
@@ -40134,6 +40251,34 @@ var FileExistsError = class extends Error {
|
|
|
40134
40251
|
path;
|
|
40135
40252
|
};
|
|
40136
40253
|
|
|
40254
|
+
// src/tools/healthcheck.ts
|
|
40255
|
+
function registerHealthcheckTools(server, state) {
|
|
40256
|
+
registerCredentialHealthcheckTool({
|
|
40257
|
+
server,
|
|
40258
|
+
prefix: "canvas",
|
|
40259
|
+
hostLabel: "canvas",
|
|
40260
|
+
probePath: "/api/v1/users/self/profile",
|
|
40261
|
+
resolveCredential: async () => {
|
|
40262
|
+
if (!state.resolved) {
|
|
40263
|
+
throw state.configError ?? new Error("Canvas auth is not configured.");
|
|
40264
|
+
}
|
|
40265
|
+
return {
|
|
40266
|
+
source: state.resolved.source,
|
|
40267
|
+
detail: {
|
|
40268
|
+
base_url: state.resolved.account.baseUrl,
|
|
40269
|
+
mode: state.resolved.account.mode
|
|
40270
|
+
}
|
|
40271
|
+
};
|
|
40272
|
+
},
|
|
40273
|
+
probeFn: async () => {
|
|
40274
|
+
const client = new CanvasClient(state.resolved.account, {
|
|
40275
|
+
refreshSession: state.resolved.refresh
|
|
40276
|
+
});
|
|
40277
|
+
return client.request("/api/v1/users/self/profile");
|
|
40278
|
+
}
|
|
40279
|
+
});
|
|
40280
|
+
}
|
|
40281
|
+
|
|
40137
40282
|
// src/tools/_shared.ts
|
|
40138
40283
|
var textContent = textResult;
|
|
40139
40284
|
function buildPath(base, params = {}) {
|
|
@@ -40502,9 +40647,12 @@ try {
|
|
|
40502
40647
|
}
|
|
40503
40648
|
await runMcp({
|
|
40504
40649
|
name: "canvas",
|
|
40505
|
-
version: "1.
|
|
40650
|
+
version: "1.5.0",
|
|
40506
40651
|
// x-release-please-version
|
|
40507
40652
|
tools: [
|
|
40653
|
+
// Outside the `if (resolved)` on purpose: an unconfigured server must
|
|
40654
|
+
// still expose something that can say WHY (see the tool's docblock).
|
|
40655
|
+
(server) => registerHealthcheckTools(server, { resolved, configError }),
|
|
40508
40656
|
(server) => {
|
|
40509
40657
|
if (resolved) {
|
|
40510
40658
|
const client = new CanvasClient(resolved.account, {
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
8
8
|
// (bundled mcpb runtime) degrades to a silent no-op.
|
|
9
9
|
await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
|
|
10
10
|
import { resolveAuth } from './auth.js';
|
|
11
|
+
import { registerHealthcheckTools } from './tools/healthcheck.js';
|
|
11
12
|
import { CanvasClient } from './client.js';
|
|
12
13
|
import { registerProfileTools } from './tools/profile.js';
|
|
13
14
|
import { registerObserveeTools } from './tools/observees.js';
|
|
@@ -46,8 +47,11 @@ catch (e) {
|
|
|
46
47
|
// host's install-time `tools/list` succeeds.
|
|
47
48
|
await runMcp({
|
|
48
49
|
name: 'canvas',
|
|
49
|
-
version: '1.
|
|
50
|
+
version: '1.5.0', // x-release-please-version
|
|
50
51
|
tools: [
|
|
52
|
+
// Outside the `if (resolved)` on purpose: an unconfigured server must
|
|
53
|
+
// still expose something that can say WHY (see the tool's docblock).
|
|
54
|
+
(server) => registerHealthcheckTools(server, { resolved, configError }),
|
|
51
55
|
(server) => {
|
|
52
56
|
if (resolved) {
|
|
53
57
|
const client = new CanvasClient(resolved.account, {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
|
|
2
|
+
import { CanvasClient } from '../client.js';
|
|
3
|
+
/**
|
|
4
|
+
* Register `canvas_healthcheck` — reports which auth path resolved, then makes
|
|
5
|
+
* one authenticated call to `/api/v1/users/self/profile`.
|
|
6
|
+
*
|
|
7
|
+
* REGISTERED UNCONDITIONALLY, unlike every other tool here. The
|
|
8
|
+
* deferred-config-error pattern registers nothing when auth fails, so an
|
|
9
|
+
* unconfigured server exposes zero tools and explains itself only on stderr —
|
|
10
|
+
* which a hosted connector never sees. That makes "the connector has no tools"
|
|
11
|
+
* indistinguishable from "the connector is broken". This one tool always
|
|
12
|
+
* exists, so there is always something that can answer why.
|
|
13
|
+
*
|
|
14
|
+
* `/api/v1/users/self/profile` is the probe because it is the cheapest
|
|
15
|
+
* endpoint requiring valid auth — its own tool description calls it the
|
|
16
|
+
* "useful first call to confirm credentials".
|
|
17
|
+
*/
|
|
18
|
+
export function registerHealthcheckTools(server, state) {
|
|
19
|
+
registerCredentialHealthcheckTool({
|
|
20
|
+
server,
|
|
21
|
+
prefix: 'canvas',
|
|
22
|
+
hostLabel: 'canvas',
|
|
23
|
+
probePath: '/api/v1/users/self/profile',
|
|
24
|
+
resolveCredential: async () => {
|
|
25
|
+
if (!state.resolved) {
|
|
26
|
+
// The stored error names every accepted auth path, which is exactly
|
|
27
|
+
// the question being asked; surface it rather than a generic message.
|
|
28
|
+
throw state.configError ?? new Error('Canvas auth is not configured.');
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
source: state.resolved.source,
|
|
32
|
+
detail: {
|
|
33
|
+
base_url: state.resolved.account.baseUrl,
|
|
34
|
+
mode: state.resolved.account.mode,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
probeFn: async () => {
|
|
39
|
+
// Built here rather than passed in: with no credential the probe is
|
|
40
|
+
// skipped entirely, so there is nothing to construct on that path.
|
|
41
|
+
const client = new CanvasClient(state.resolved.account, {
|
|
42
|
+
refreshSession: state.resolved.refresh,
|
|
43
|
+
});
|
|
44
|
+
return client.request('/api/v1/users/self/profile');
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvas-parent-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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>",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@chrischall/mcp-utils": "^0.
|
|
50
|
+
"@chrischall/mcp-utils": "^0.19.2",
|
|
51
51
|
"@fetchproxy/bootstrap": "^2.2.0",
|
|
52
52
|
"@fetchproxy/server": "^2.2.0",
|
|
53
53
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
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": "1.
|
|
9
|
+
"version": "1.5.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "canvas-parent-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.5.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -56,6 +56,7 @@ Precedence when multiple are set: `CANVAS_TOKEN` > username/password > OAuth > f
|
|
|
56
56
|
|
|
57
57
|
### Profile & observees
|
|
58
58
|
- `canvas_get_profile` — your Canvas profile
|
|
59
|
+
- `canvas_healthcheck` — which auth path resolved and whether Canvas accepts it; registered even when auth is unconfigured, so it can say why
|
|
59
60
|
- `canvas_list_observees` — students linked to your observer account
|
|
60
61
|
|
|
61
62
|
### Courses
|