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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/bundle.js +203 -16
- package/dist/index.js +16 -2
- package/dist/tools/healthcheck.js +62 -0
- package/package.json +4 -4
- package/server.json +2 -2
- package/skills/ic/SKILL.md +9 -2
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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;
|
|
@@ -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.
|
|
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.
|
|
39094
|
-
"@fetchproxy/bootstrap": "^2.
|
|
39095
|
-
"@fetchproxy/server": "^2.
|
|
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.
|
|
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({
|
|
41042
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
49
|
-
"@fetchproxy/bootstrap": "^2.
|
|
50
|
-
"@fetchproxy/server": "^2.
|
|
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.
|
|
9
|
+
"version": "2.7.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "infinitecampus-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.7.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/skills/ic/SKILL.md
CHANGED
|
@@ -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) —
|
|
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`).
|
|
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
|
|