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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/bundle.js +181 -5
- package/dist/index.js +16 -2
- package/dist/tools/healthcheck.js +62 -0
- package/package.json +2 -2
- 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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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({
|
|
41053
|
-
|
|
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.
|
|
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,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@chrischall/mcp-utils": "^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.
|
|
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
|
|