infinitecampus-mcp 2.5.1 → 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.5.1"
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.5.1",
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.5.1",
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
@@ -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;
@@ -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) {
@@ -36959,6 +36972,7 @@ function classifyBridgeError(err) {
36959
36972
  }
36960
36973
 
36961
36974
  // node_modules/@fetchproxy/server/dist/ws-server.js
36975
+ import { isIP } from "node:net";
36962
36976
  function envWsPort() {
36963
36977
  const raw = process.env.FETCHPROXY_WS_PORT;
36964
36978
  if (raw === void 0 || raw.trim() === "")
@@ -36970,6 +36984,15 @@ function envWsPort() {
36970
36984
  return void 0;
36971
36985
  return port;
36972
36986
  }
36987
+ function envWsHost() {
36988
+ const raw = process.env.FETCHPROXY_WS_HOST;
36989
+ if (raw === void 0)
36990
+ return void 0;
36991
+ const host = raw.trim();
36992
+ if (host === "" || isIP(host) === 0)
36993
+ return void 0;
36994
+ return host;
36995
+ }
36973
36996
  var FetchproxyProtocolError = class extends Error {
36974
36997
  constructor(message) {
36975
36998
  super(message);
@@ -37218,7 +37241,7 @@ var FetchproxyServer = class {
37218
37241
  }
37219
37242
  this.opts = {
37220
37243
  port: opts.port ?? envWsPort() ?? 37149,
37221
- host: opts.host ?? "127.0.0.1",
37244
+ host: opts.host ?? envWsHost() ?? "127.0.0.1",
37222
37245
  serverName: opts.serverName,
37223
37246
  version: opts.version,
37224
37247
  domains: [...opts.domains],
@@ -37504,6 +37527,7 @@ var FetchproxyServer = class {
37504
37527
  return {
37505
37528
  role: this.role,
37506
37529
  port: this.opts.port,
37530
+ host: this.opts.host,
37507
37531
  serverVersion: this.opts.version,
37508
37532
  fetchTimeoutMs: this.opts.fetchTimeoutMs ?? 0,
37509
37533
  bridgeReviveDelayMs: this.opts.bridgeReviveDelayMs ?? 0,
@@ -39004,6 +39028,118 @@ var BootstrapDisabledError = class extends Error {
39004
39028
  }
39005
39029
  };
39006
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
+
39007
39143
  // src/config.ts
39008
39144
  function readVar(env, key) {
39009
39145
  return readEnvVar(key, { env });
@@ -39045,7 +39181,7 @@ function loadAccount(env = process.env) {
39045
39181
  // package.json
39046
39182
  var package_default = {
39047
39183
  name: "infinitecampus-mcp",
39048
- version: "2.5.1",
39184
+ version: "2.7.0",
39049
39185
  mcpName: "io.github.chrischall/infinitecampus-mcp",
39050
39186
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
39051
39187
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -39090,9 +39226,9 @@ var package_default = {
39090
39226
  typecheck: "tsc -p tsconfig.json --noEmit"
39091
39227
  },
39092
39228
  dependencies: {
39093
- "@chrischall/mcp-utils": "^0.18.0",
39094
- "@fetchproxy/bootstrap": "^2.0.0",
39095
- "@fetchproxy/server": "^2.0.0",
39229
+ "@chrischall/mcp-utils": "^0.19.2",
39230
+ "@fetchproxy/bootstrap": "^2.2.0",
39231
+ "@fetchproxy/server": "^2.2.0",
39096
39232
  "@modelcontextprotocol/sdk": "^1.29.0",
39097
39233
  dotenv: "^17.4.2",
39098
39234
  "node-html-parser": "^9.0.0",
@@ -40114,6 +40250,50 @@ function registerDistrictTools(server, client) {
40114
40250
  });
40115
40251
  }
40116
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
+
40117
40297
  // src/tools/students.ts
40118
40298
  var argsSchema = external_exports.object({
40119
40299
  district: external_exports.string().describe("District name from ic_list_districts")
@@ -41007,12 +41187,15 @@ try {
41007
41187
  }
41008
41188
  var COMMON = {
41009
41189
  name: "infinitecampus",
41010
- version: "2.5.1"
41190
+ version: "2.7.0"
41011
41191
  // x-release-please-version
41012
41192
  };
41193
+ var healthState = { account, source, configError, client: null };
41013
41194
  if (account) {
41014
41195
  const client = new ICClient(account, { refreshSession });
41196
+ healthState.client = client;
41015
41197
  const tools = [
41198
+ (server) => registerHealthcheckTools(server, healthState),
41016
41199
  registerDistrictTools,
41017
41200
  registerStudentTools,
41018
41201
  registerScheduleTools,
@@ -41038,6 +41221,10 @@ ${AI_NOTICE}` });
41038
41221
  } else {
41039
41222
  const notConfigured = `[infinitecampus-mcp] Not configured: ${configError?.message ?? "unknown error"}
41040
41223
  [infinitecampus-mcp] Server is running with no tools registered. Set the required env vars and reinstall.`;
41041
- await runMcp({ ...COMMON, tools: [], banner: `${notConfigured}
41042
- ${AI_NOTICE}` });
41224
+ await runMcp({
41225
+ ...COMMON,
41226
+ tools: [(server) => registerHealthcheckTools(server, healthState)],
41227
+ banner: `${notConfigured}
41228
+ ${AI_NOTICE}`
41229
+ });
41043
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.5.1', // 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.5.1",
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,9 +45,9 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^0.18.0",
49
- "@fetchproxy/bootstrap": "^2.0.0",
50
- "@fetchproxy/server": "^2.0.0",
48
+ "@chrischall/mcp-utils": "^0.19.2",
49
+ "@fetchproxy/bootstrap": "^2.2.0",
50
+ "@fetchproxy/server": "^2.2.0",
51
51
  "@modelcontextprotocol/sdk": "^1.29.0",
52
52
  "dotenv": "^17.4.2",
53
53
  "node-html-parser": "^9.0.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.5.1",
9
+ "version": "2.7.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.5.1",
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