canvas-parent-mcp 1.4.0 → 1.5.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.
@@ -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.4.0"
10
+ "version": "1.5.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": "1.4.0",
18
+ "version": "1.5.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": "1.4.0",
4
+ "version": "1.5.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/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.4.0",
39245
+ version: "1.5.1",
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.17.1",
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.4.0",
40650
+ version: "1.5.1",
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.4.0', // x-release-please-version
50
+ version: '1.5.1', // 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.4.0",
3
+ "version": "1.5.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>",
@@ -47,7 +47,7 @@
47
47
  "typecheck": "tsc -p tsconfig.json --noEmit"
48
48
  },
49
49
  "dependencies": {
50
- "@chrischall/mcp-utils": "^0.17.1",
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.4.0",
9
+ "version": "1.5.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "canvas-parent-mcp",
14
- "version": "1.4.0",
14
+ "version": "1.5.1",
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