infinitecampus-mcp 2.6.0 → 2.7.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 Infinite Campus (Campus Parent) — grades, attendance, assignments, messages, documents",
10
- "version": "2.6.0"
10
+ "version": "2.7.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Infinite Campus",
16
16
  "source": "./",
17
17
  "description": "Infinite Campus (Campus Parent) MCP server for Claude — grades, attendance, assignments, messages, and documents via natural language",
18
- "version": "2.6.0",
18
+ "version": "2.7.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
3
  "displayName": "Infinite Campus",
4
- "version": "2.6.0",
4
+ "version": "2.7.0",
5
5
  "description": "Infinite Campus (Campus Parent) MCP server for Claude — grades, attendance, assignments, messages, and documents",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -8,7 +8,7 @@ MCP server for Infinite Campus (Campus Parent portal). Single-account config —
8
8
 
9
9
  ## Tools
10
10
 
11
- 19 tools across academics, daily life, documents, messaging, and feature discovery.
11
+ 20 tools across academics, daily life, documents, messaging, feature discovery, and a credential/district healthcheck.
12
12
 
13
13
  | Domain | Tools |
14
14
  |---|---|
package/dist/bundle.js CHANGED
@@ -34682,6 +34682,7 @@ var StdioServerTransport = class {
34682
34682
  };
34683
34683
 
34684
34684
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
34685
+ var DEFAULT_ERROR_MESSAGE_MAX = 500;
34685
34686
  var McpToolError = class extends Error {
34686
34687
  /** Actionable remediation text, when one applies. */
34687
34688
  hint;
@@ -34720,6 +34721,18 @@ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*('
34720
34721
  function redactSecrets(text) {
34721
34722
  return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
34722
34723
  }
34724
+ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
34725
+ const str = text === null || text === void 0 ? "" : String(text);
34726
+ const redacted = redactSecrets(str);
34727
+ if (redacted.length <= max)
34728
+ return redacted;
34729
+ return `${redacted.slice(0, max)}\u2026 [truncated]`;
34730
+ }
34731
+ function messageOf(err) {
34732
+ if (err instanceof Error)
34733
+ return err.message;
34734
+ return String(err);
34735
+ }
34723
34736
 
34724
34737
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
34725
34738
  function textResult(data) {
@@ -39015,6 +39028,118 @@ var BootstrapDisabledError = class extends Error {
39015
39028
  }
39016
39029
  };
39017
39030
 
39031
+ // node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
39032
+ function statusOf(err) {
39033
+ if (typeof err !== "object" || err === null)
39034
+ return void 0;
39035
+ const s = err.status ?? err.statusCode;
39036
+ return typeof s === "number" ? s : void 0;
39037
+ }
39038
+ function credentialHint(arm, prefix, hostLabel, source2) {
39039
+ switch (arm) {
39040
+ case "ok":
39041
+ return `Credential from '${source2}' works: ${hostLabel} accepted an authenticated request. If a real tool still fails, the problem is that tool, not auth.`;
39042
+ case "no_credential":
39043
+ 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.`;
39044
+ case "credential_rejected":
39045
+ return `${hostLabel} rejected the credential from '${source2}'. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
39046
+ case "timeout":
39047
+ return `The credential from '${source2}' resolved, but ${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
39048
+ case "http":
39049
+ 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.`;
39050
+ case "transport":
39051
+ return `Could not reach ${hostLabel} at all. Check network egress; the credential itself was never judged.`;
39052
+ default:
39053
+ return `Unexpected failure \u2014 see error.message.`;
39054
+ }
39055
+ }
39056
+ function registerCredentialHealthcheckTool(args) {
39057
+ const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
39058
+ const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
39059
+ server.registerTool(`${prefix}_healthcheck`, {
39060
+ title: "Verify credentials and upstream reachability",
39061
+ 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.`,
39062
+ annotations: {
39063
+ title: "Verify credentials and upstream reachability",
39064
+ readOnlyHint: true,
39065
+ idempotentHint: true,
39066
+ openWorldHint: true
39067
+ },
39068
+ inputSchema: {}
39069
+ }, async () => {
39070
+ let probeStarted = 0;
39071
+ let state;
39072
+ try {
39073
+ state = await resolveCredential();
39074
+ } catch (e) {
39075
+ const result2 = {
39076
+ ok: false,
39077
+ credential: { source: null, resolved: false },
39078
+ // No `url`: nothing was probed, and naming one implies it was tried.
39079
+ probe: { elapsed_ms: 0 },
39080
+ error: { kind: "no_credential", message: truncateErrorMessage(messageOf(e)) },
39081
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
39082
+ };
39083
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
39084
+ }
39085
+ const credential = {
39086
+ source: state.source,
39087
+ resolved: state.source !== null,
39088
+ ...state.detail !== void 0 ? { detail: state.detail } : {}
39089
+ };
39090
+ if (!credential.resolved) {
39091
+ const result2 = {
39092
+ ok: false,
39093
+ credential,
39094
+ probe: { elapsed_ms: 0 },
39095
+ error: { kind: "no_credential", message: "no credential source resolved" },
39096
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
39097
+ };
39098
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
39099
+ }
39100
+ let arm = "ok";
39101
+ let error51;
39102
+ let status;
39103
+ let customHint;
39104
+ probeStarted = Date.now();
39105
+ try {
39106
+ await probeFn();
39107
+ } catch (e) {
39108
+ status = statusOf(e);
39109
+ const aborted2 = e instanceof Error && e.name === "AbortError";
39110
+ 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";
39111
+ let kind = arm;
39112
+ let detail;
39113
+ const custom2 = classifyThrown?.(e);
39114
+ if (custom2) {
39115
+ kind = custom2.kind;
39116
+ customHint = custom2.hint;
39117
+ detail = custom2.detail;
39118
+ }
39119
+ error51 = {
39120
+ kind,
39121
+ // Redacted AND bounded before it reaches the result: an upstream
39122
+ // failure routinely quotes what it was sent, and a healthcheck is
39123
+ // the tool people paste into a chat when something is broken.
39124
+ message: truncateErrorMessage(messageOf(e)),
39125
+ ...detail !== void 0 ? { detail } : {}
39126
+ };
39127
+ }
39128
+ const result = {
39129
+ ok: error51 === void 0,
39130
+ credential,
39131
+ probe: {
39132
+ ...probeUrl ? { url: probeUrl } : {},
39133
+ elapsed_ms: Date.now() - probeStarted,
39134
+ ...status !== void 0 ? { status } : {}
39135
+ },
39136
+ ...error51 ? { error: error51 } : {},
39137
+ hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
39138
+ };
39139
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
39140
+ });
39141
+ }
39142
+
39018
39143
  // src/config.ts
39019
39144
  function readVar(env, key) {
39020
39145
  return readEnvVar(key, { env });
@@ -39056,7 +39181,7 @@ function loadAccount(env = process.env) {
39056
39181
  // package.json
39057
39182
  var package_default = {
39058
39183
  name: "infinitecampus-mcp",
39059
- version: "2.6.0",
39184
+ version: "2.7.0",
39060
39185
  mcpName: "io.github.chrischall/infinitecampus-mcp",
39061
39186
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
39062
39187
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -39101,7 +39226,7 @@ var package_default = {
39101
39226
  typecheck: "tsc -p tsconfig.json --noEmit"
39102
39227
  },
39103
39228
  dependencies: {
39104
- "@chrischall/mcp-utils": "^0.18.0",
39229
+ "@chrischall/mcp-utils": "^0.19.2",
39105
39230
  "@fetchproxy/bootstrap": "^2.2.0",
39106
39231
  "@fetchproxy/server": "^2.2.0",
39107
39232
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -40125,6 +40250,50 @@ function registerDistrictTools(server, client) {
40125
40250
  });
40126
40251
  }
40127
40252
 
40253
+ // src/tools/healthcheck.ts
40254
+ function registerHealthcheckTools(server, state) {
40255
+ registerCredentialHealthcheckTool({
40256
+ server,
40257
+ prefix: "ic",
40258
+ hostLabel: "your Infinite Campus portal",
40259
+ resolveCredential: async () => {
40260
+ if (!state.account || !state.client) {
40261
+ throw state.configError ?? new Error("Infinite Campus is not configured.");
40262
+ }
40263
+ const detail = {
40264
+ base_url: state.account.baseUrl,
40265
+ configured_district: state.account.district,
40266
+ auth_source: state.source ?? "unknown"
40267
+ };
40268
+ try {
40269
+ await state.client.ensureDiscovery();
40270
+ const districts = state.client.listDistricts();
40271
+ detail.districts = districts.map((d) => ({ name: d.name, linked: d.linked }));
40272
+ const linked = districts.filter((d) => d.linked).map((d) => d.name);
40273
+ detail.configured_district_is_linked = districts.some(
40274
+ (d) => d.name === state.account.district && d.linked
40275
+ );
40276
+ if (linked.length > 0) detail.linked_districts = linked;
40277
+ } catch {
40278
+ detail.districts = "unavailable (discovery needs a live session)";
40279
+ }
40280
+ return { source: state.source ?? "env", detail };
40281
+ },
40282
+ // Probe the district the account is actually LINKED to when that differs
40283
+ // from the configured one — probing a district you are not enrolled in
40284
+ // fails for a reason that has nothing to do with your credentials.
40285
+ probeFn: () => {
40286
+ const districts = state.client.listDistricts();
40287
+ const linked = districts.find((d) => d.linked);
40288
+ const district = linked?.name ?? state.account.district;
40289
+ return state.client.request(district, "/campus/api/portal/students");
40290
+ },
40291
+ hints: {
40292
+ credential_rejected: "Infinite Campus rejected the credentials. Check IC_USERNAME/IC_PASSWORD, or sign into your IC portal in the browser so the fetchproxy fallback can lift a session."
40293
+ }
40294
+ });
40295
+ }
40296
+
40128
40297
  // src/tools/students.ts
40129
40298
  var argsSchema = external_exports.object({
40130
40299
  district: external_exports.string().describe("District name from ic_list_districts")
@@ -41018,12 +41187,15 @@ try {
41018
41187
  }
41019
41188
  var COMMON = {
41020
41189
  name: "infinitecampus",
41021
- version: "2.6.0"
41190
+ version: "2.7.0"
41022
41191
  // x-release-please-version
41023
41192
  };
41193
+ var healthState = { account, source, configError, client: null };
41024
41194
  if (account) {
41025
41195
  const client = new ICClient(account, { refreshSession });
41196
+ healthState.client = client;
41026
41197
  const tools = [
41198
+ (server) => registerHealthcheckTools(server, healthState),
41027
41199
  registerDistrictTools,
41028
41200
  registerStudentTools,
41029
41201
  registerScheduleTools,
@@ -41049,6 +41221,10 @@ ${AI_NOTICE}` });
41049
41221
  } else {
41050
41222
  const notConfigured = `[infinitecampus-mcp] Not configured: ${configError?.message ?? "unknown error"}
41051
41223
  [infinitecampus-mcp] Server is running with no tools registered. Set the required env vars and reinstall.`;
41052
- await runMcp({ ...COMMON, tools: [], banner: `${notConfigured}
41053
- ${AI_NOTICE}` });
41224
+ await runMcp({
41225
+ ...COMMON,
41226
+ tools: [(server) => registerHealthcheckTools(server, healthState)],
41227
+ banner: `${notConfigured}
41228
+ ${AI_NOTICE}`
41229
+ });
41054
41230
  }
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ await loadDotenvSafely({
14
14
  import { resolveAuth } from './auth.js';
15
15
  import { ICClient } from './client.js';
16
16
  import { registerDistrictTools } from './tools/districts.js';
17
+ import { registerHealthcheckTools } from './tools/healthcheck.js';
17
18
  import { registerStudentTools } from './tools/students.js';
18
19
  import { registerScheduleTools } from './tools/schedule.js';
19
20
  import { registerAssignmentTools } from './tools/assignments.js';
@@ -57,11 +58,16 @@ catch (e) {
57
58
  // actionable stderr message (banner) instead of a crash loop.
58
59
  const COMMON = {
59
60
  name: 'infinitecampus',
60
- version: '2.6.0', // x-release-please-version
61
+ version: '2.7.0', // x-release-please-version
61
62
  };
63
+ // Shared with the healthcheck so it can report the SAME state the tools see,
64
+ // including on the unconfigured path where there are no tools at all.
65
+ const healthState = { account, source, configError, client: null };
62
66
  if (account) {
63
67
  const client = new ICClient(account, { refreshSession });
68
+ healthState.client = client;
64
69
  const tools = [
70
+ (server) => registerHealthcheckTools(server, healthState),
65
71
  registerDistrictTools,
66
72
  registerStudentTools,
67
73
  registerScheduleTools,
@@ -87,5 +93,13 @@ if (account) {
87
93
  else {
88
94
  const notConfigured = `[infinitecampus-mcp] Not configured: ${configError?.message ?? 'unknown error'}\n` +
89
95
  '[infinitecampus-mcp] Server is running with no tools registered. Set the required env vars and reinstall.';
90
- await runMcp({ ...COMMON, tools: [], banner: `${notConfigured}\n${AI_NOTICE}` });
96
+ // NOT `tools: []` any more. A server that registers nothing explains itself
97
+ // only on stderr, which a hosted connector never sees — so a misconfigured
98
+ // server was indistinguishable from a broken one. The healthcheck is the one
99
+ // tool that must survive this path, because this is the path that needs it.
100
+ await runMcp({
101
+ ...COMMON,
102
+ tools: [(server) => registerHealthcheckTools(server, healthState)],
103
+ banner: `${notConfigured}\n${AI_NOTICE}`,
104
+ });
91
105
  }
@@ -0,0 +1,62 @@
1
+ import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
2
+ /**
3
+ * Register `ic_healthcheck` — reports which auth path resolved and which
4
+ * districts the account is actually LINKED to, then makes one authenticated
5
+ * call.
6
+ *
7
+ * REGISTERED UNCONDITIONALLY. When `IC_BASE_URL`/`IC_DISTRICT` are missing,
8
+ * `index.ts` registers zero tools and reports the reason on stderr only —
9
+ * invisible to a hosted connector, so a misconfigured server looks like a
10
+ * broken one.
11
+ *
12
+ * The district detail is the specific thing that cost real debugging time
13
+ * here: a configured `IC_DISTRICT` the account is NOT linked to fails every
14
+ * tool with an unrelated-looking error, while `ic_list_districts` quietly
15
+ * shows `linked: false` next to a different district that works. Surfacing
16
+ * "configured X, linked to Y" turns that into one glance.
17
+ */
18
+ export function registerHealthcheckTools(server, state) {
19
+ registerCredentialHealthcheckTool({
20
+ server,
21
+ prefix: 'ic',
22
+ hostLabel: 'your Infinite Campus portal',
23
+ resolveCredential: async () => {
24
+ if (!state.account || !state.client) {
25
+ throw state.configError ?? new Error('Infinite Campus is not configured.');
26
+ }
27
+ const detail = {
28
+ base_url: state.account.baseUrl,
29
+ configured_district: state.account.district,
30
+ auth_source: state.source ?? 'unknown',
31
+ };
32
+ try {
33
+ // Discovery is what knows about LINKED districts, and it needs a live
34
+ // session — so a failure here is not a credential answer. Report what
35
+ // is known and let the probe below decide.
36
+ await state.client.ensureDiscovery();
37
+ const districts = state.client.listDistricts();
38
+ detail.districts = districts.map((d) => ({ name: d.name, linked: d.linked }));
39
+ const linked = districts.filter((d) => d.linked).map((d) => d.name);
40
+ detail.configured_district_is_linked = districts.some((d) => d.name === state.account.district && d.linked);
41
+ if (linked.length > 0)
42
+ detail.linked_districts = linked;
43
+ }
44
+ catch {
45
+ detail.districts = 'unavailable (discovery needs a live session)';
46
+ }
47
+ return { source: state.source ?? 'env', detail };
48
+ },
49
+ // Probe the district the account is actually LINKED to when that differs
50
+ // from the configured one — probing a district you are not enrolled in
51
+ // fails for a reason that has nothing to do with your credentials.
52
+ probeFn: () => {
53
+ const districts = state.client.listDistricts();
54
+ const linked = districts.find((d) => d.linked);
55
+ const district = linked?.name ?? state.account.district;
56
+ return state.client.request(district, '/campus/api/portal/students');
57
+ },
58
+ hints: {
59
+ credential_rejected: 'Infinite Campus rejected the credentials. Check IC_USERNAME/IC_PASSWORD, or sign into your IC portal in the browser so the fetchproxy fallback can lift a session.',
60
+ },
61
+ });
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "mcpName": "io.github.chrischall/infinitecampus-mcp",
5
5
  "description": "Infinite Campus (Campus Parent) MCP server — multi-district read + message/document write",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^0.18.0",
48
+ "@chrischall/mcp-utils": "^0.19.2",
49
49
  "@fetchproxy/bootstrap": "^2.2.0",
50
50
  "@fetchproxy/server": "^2.2.0",
51
51
  "@modelcontextprotocol/sdk": "^1.29.0",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/infinitecampus-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.6.0",
9
+ "version": "2.7.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.6.0",
14
+ "version": "2.7.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -5,7 +5,7 @@ description: This skill should be used when the user asks about Infinite Campus
5
5
 
6
6
  # infinitecampus-mcp
7
7
 
8
- MCP server for Infinite Campus (Campus Parent portal) — 19 tools covering schedule, grades (current + recently-scored), assignments, attendance (summary + per-event), behavior, food service, documents, messages (3 sources), teachers, assessments, and fees. Linked districts are auto-discovered via CUPS SSO after primary login.
8
+ MCP server for Infinite Campus (Campus Parent portal) — 20 tools covering schedule, grades (current + recently-scored), assignments, attendance (summary + per-event), behavior, food service, documents, messages (3 sources), teachers, assessments, and fees. Linked districts are auto-discovered via CUPS SSO after primary login.
9
9
 
10
10
  - **Source:** [github.com/chrischall/infinitecampus-mcp](https://github.com/chrischall/infinitecampus-mcp)
11
11
  - **npm:** [npmjs.com/package/infinitecampus-mcp](https://www.npmjs.com/package/infinitecampus-mcp)
@@ -89,7 +89,14 @@ mcporter call ic.<tool_name> [key=value ...] --config ~/.mcporter/mcporter.json
89
89
 
90
90
  Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter.json` exists.
91
91
 
92
- Every tool except `ic_list_districts` takes `district` as its first arg (the district name from `ic_list_districts`). Most student-scoped tools also take `studentId` (the personID from `ic_list_students`).
92
+ Every tool except `ic_list_districts` takes `district` as its first arg (the district name from `ic_list_districts`).
93
+
94
+ `ic_healthcheck` takes no args and is the exception worth knowing: it is
95
+ registered even when the server is unconfigured (every other tool is not), and
96
+ it reports whether the configured `IC_DISTRICT` is one the account is actually
97
+ LINKED to. A district you are not linked to fails every other tool for a reason
98
+ that looks nothing like "wrong district", so run this first when things fail
99
+ inexplicably. Most student-scoped tools also take `studentId` (the personID from `ic_list_students`).
93
100
 
94
101
  ## Tools
95
102