canvas-parent-mcp 1.3.1 → 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 +173 -14
- package/dist/index.js +5 -1
- package/dist/tools/healthcheck.js +47 -0
- package/package.json +4 -4
- 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
|
@@ -3314,8 +3314,8 @@ var require_utils = __commonJS({
|
|
|
3314
3314
|
var HOST_DELIMS = { "@": "%40", "/": "%2F", "?": "%3F", "#": "%23", ":": "%3A" };
|
|
3315
3315
|
var HOST_DELIM_RE = /[@/?#:]/g;
|
|
3316
3316
|
var HOST_DELIM_NO_COLON_RE = /[@/?#]/g;
|
|
3317
|
-
function reescapeHostDelimiters(host,
|
|
3318
|
-
const re =
|
|
3317
|
+
function reescapeHostDelimiters(host, isIP2) {
|
|
3318
|
+
const re = isIP2 ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_RE;
|
|
3319
3319
|
re.lastIndex = 0;
|
|
3320
3320
|
return host.replace(re, (ch) => HOST_DELIMS[ch]);
|
|
3321
3321
|
}
|
|
@@ -3804,7 +3804,7 @@ var require_fast_uri = __commonJS({
|
|
|
3804
3804
|
fragment: void 0
|
|
3805
3805
|
};
|
|
3806
3806
|
let malformedAuthorityOrPort = false;
|
|
3807
|
-
let
|
|
3807
|
+
let isIP2 = false;
|
|
3808
3808
|
if (options.reference === "suffix") {
|
|
3809
3809
|
if (options.scheme) {
|
|
3810
3810
|
uri = options.scheme + ":" + uri;
|
|
@@ -3853,9 +3853,9 @@ var require_fast_uri = __commonJS({
|
|
|
3853
3853
|
if (ipv4result === false) {
|
|
3854
3854
|
const ipv6result = normalizeIPv6(parsed.host);
|
|
3855
3855
|
parsed.host = ipv6result.host.toLowerCase();
|
|
3856
|
-
|
|
3856
|
+
isIP2 = ipv6result.isIPV6;
|
|
3857
3857
|
} else {
|
|
3858
|
-
|
|
3858
|
+
isIP2 = true;
|
|
3859
3859
|
}
|
|
3860
3860
|
}
|
|
3861
3861
|
if (parsed.scheme === void 0 && parsed.userinfo === void 0 && parsed.host === void 0 && parsed.port === void 0 && parsed.query === void 0 && !parsed.path) {
|
|
@@ -3872,7 +3872,7 @@ var require_fast_uri = __commonJS({
|
|
|
3872
3872
|
}
|
|
3873
3873
|
const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme);
|
|
3874
3874
|
if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
|
|
3875
|
-
if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) &&
|
|
3875
|
+
if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP2 === false && nonSimpleDomain(parsed.host)) {
|
|
3876
3876
|
try {
|
|
3877
3877
|
parsed.host = new URL("http://" + parsed.host).hostname;
|
|
3878
3878
|
} catch (e) {
|
|
@@ -3886,7 +3886,7 @@ var require_fast_uri = __commonJS({
|
|
|
3886
3886
|
parsed.scheme = unescape(parsed.scheme);
|
|
3887
3887
|
}
|
|
3888
3888
|
if (parsed.host !== void 0) {
|
|
3889
|
-
parsed.host = reescapeHostDelimiters(unescape(parsed.host),
|
|
3889
|
+
parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP2);
|
|
3890
3890
|
}
|
|
3891
3891
|
}
|
|
3892
3892
|
if (parsed.path) {
|
|
@@ -7199,7 +7199,7 @@ var require_permessage_deflate = __commonJS({
|
|
|
7199
7199
|
acceptAsServer(offers) {
|
|
7200
7200
|
const opts = this._options;
|
|
7201
7201
|
const accepted = offers.find((params) => {
|
|
7202
|
-
if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) {
|
|
7202
|
+
if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && (typeof params.client_max_window_bits === "number" ? opts.clientMaxWindowBits > params.client_max_window_bits : !params.client_max_window_bits)) {
|
|
7203
7203
|
return false;
|
|
7204
7204
|
}
|
|
7205
7205
|
return true;
|
|
@@ -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) {
|
|
@@ -37096,6 +37101,7 @@ function classifyBridgeError(err) {
|
|
|
37096
37101
|
}
|
|
37097
37102
|
|
|
37098
37103
|
// node_modules/@fetchproxy/server/dist/ws-server.js
|
|
37104
|
+
import { isIP } from "node:net";
|
|
37099
37105
|
function envWsPort() {
|
|
37100
37106
|
const raw = process.env.FETCHPROXY_WS_PORT;
|
|
37101
37107
|
if (raw === void 0 || raw.trim() === "")
|
|
@@ -37107,6 +37113,15 @@ function envWsPort() {
|
|
|
37107
37113
|
return void 0;
|
|
37108
37114
|
return port;
|
|
37109
37115
|
}
|
|
37116
|
+
function envWsHost() {
|
|
37117
|
+
const raw = process.env.FETCHPROXY_WS_HOST;
|
|
37118
|
+
if (raw === void 0)
|
|
37119
|
+
return void 0;
|
|
37120
|
+
const host = raw.trim();
|
|
37121
|
+
if (host === "" || isIP(host) === 0)
|
|
37122
|
+
return void 0;
|
|
37123
|
+
return host;
|
|
37124
|
+
}
|
|
37110
37125
|
var FetchproxyProtocolError = class extends Error {
|
|
37111
37126
|
constructor(message) {
|
|
37112
37127
|
super(message);
|
|
@@ -37355,7 +37370,7 @@ var FetchproxyServer = class {
|
|
|
37355
37370
|
}
|
|
37356
37371
|
this.opts = {
|
|
37357
37372
|
port: opts.port ?? envWsPort() ?? 37149,
|
|
37358
|
-
host: opts.host ?? "127.0.0.1",
|
|
37373
|
+
host: opts.host ?? envWsHost() ?? "127.0.0.1",
|
|
37359
37374
|
serverName: opts.serverName,
|
|
37360
37375
|
version: opts.version,
|
|
37361
37376
|
domains: [...opts.domains],
|
|
@@ -37641,6 +37656,7 @@ var FetchproxyServer = class {
|
|
|
37641
37656
|
return {
|
|
37642
37657
|
role: this.role,
|
|
37643
37658
|
port: this.opts.port,
|
|
37659
|
+
host: this.opts.host,
|
|
37644
37660
|
serverVersion: this.opts.version,
|
|
37645
37661
|
fetchTimeoutMs: this.opts.fetchTimeoutMs ?? 0,
|
|
37646
37662
|
bridgeReviveDelayMs: this.opts.bridgeReviveDelayMs ?? 0,
|
|
@@ -39226,7 +39242,7 @@ function loadAccount(env = process.env) {
|
|
|
39226
39242
|
// package.json
|
|
39227
39243
|
var package_default = {
|
|
39228
39244
|
name: "canvas-parent-mcp",
|
|
39229
|
-
version: "1.
|
|
39245
|
+
version: "1.5.0",
|
|
39230
39246
|
mcpName: "io.github.chrischall/canvas-parent-mcp",
|
|
39231
39247
|
description: "Canvas LMS (Instructure) MCP server \u2014 student/observer read access with optional file download",
|
|
39232
39248
|
author: "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -39273,9 +39289,9 @@ var package_default = {
|
|
|
39273
39289
|
typecheck: "tsc -p tsconfig.json --noEmit"
|
|
39274
39290
|
},
|
|
39275
39291
|
dependencies: {
|
|
39276
|
-
"@chrischall/mcp-utils": "^0.
|
|
39277
|
-
"@fetchproxy/bootstrap": "^2.
|
|
39278
|
-
"@fetchproxy/server": "^2.
|
|
39292
|
+
"@chrischall/mcp-utils": "^0.19.2",
|
|
39293
|
+
"@fetchproxy/bootstrap": "^2.2.0",
|
|
39294
|
+
"@fetchproxy/server": "^2.2.0",
|
|
39279
39295
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
39280
39296
|
dotenv: "^17.4.2",
|
|
39281
39297
|
zod: "^4.4.3"
|
|
@@ -39381,6 +39397,118 @@ async function liftBrowserCookie(declaredDomain, baseHost) {
|
|
|
39381
39397
|
}
|
|
39382
39398
|
}
|
|
39383
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
|
+
|
|
39384
39512
|
// src/client.ts
|
|
39385
39513
|
import { writeFile as writeFile3, stat } from "fs/promises";
|
|
39386
39514
|
|
|
@@ -40123,6 +40251,34 @@ var FileExistsError = class extends Error {
|
|
|
40123
40251
|
path;
|
|
40124
40252
|
};
|
|
40125
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
|
+
|
|
40126
40282
|
// src/tools/_shared.ts
|
|
40127
40283
|
var textContent = textResult;
|
|
40128
40284
|
function buildPath(base, params = {}) {
|
|
@@ -40491,9 +40647,12 @@ try {
|
|
|
40491
40647
|
}
|
|
40492
40648
|
await runMcp({
|
|
40493
40649
|
name: "canvas",
|
|
40494
|
-
version: "1.
|
|
40650
|
+
version: "1.5.0",
|
|
40495
40651
|
// x-release-please-version
|
|
40496
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 }),
|
|
40497
40656
|
(server) => {
|
|
40498
40657
|
if (resolved) {
|
|
40499
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,9 +47,9 @@
|
|
|
47
47
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@chrischall/mcp-utils": "^0.
|
|
51
|
-
"@fetchproxy/bootstrap": "^2.
|
|
52
|
-
"@fetchproxy/server": "^2.
|
|
50
|
+
"@chrischall/mcp-utils": "^0.19.2",
|
|
51
|
+
"@fetchproxy/bootstrap": "^2.2.0",
|
|
52
|
+
"@fetchproxy/server": "^2.2.0",
|
|
53
53
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
54
54
|
"dotenv": "^17.4.2",
|
|
55
55
|
"zod": "^4.4.3"
|
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
|