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.
@@ -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.3.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.3.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.3.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, isIP) {
3318
- const re = isIP ? HOST_DELIM_NO_COLON_RE : HOST_DELIM_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 isIP = false;
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
- isIP = ipv6result.isIPV6;
3856
+ isIP2 = ipv6result.isIPV6;
3857
3857
  } else {
3858
- isIP = true;
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) && isIP === false && nonSimpleDomain(parsed.host)) {
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), isIP);
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.3.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.17.1",
39277
- "@fetchproxy/bootstrap": "^2.0.0",
39278
- "@fetchproxy/server": "^2.0.0",
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.3.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.3.1', // x-release-please-version
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.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.17.1",
51
- "@fetchproxy/bootstrap": "^2.0.0",
52
- "@fetchproxy/server": "^2.0.0",
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.3.1",
9
+ "version": "1.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "1.3.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