infinitecampus-mcp 2.0.0 → 2.0.2

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/README.md CHANGED
@@ -4,18 +4,25 @@ MCP server for Infinite Campus (Campus Parent portal). Single-account config —
4
4
 
5
5
  ## Tools
6
6
 
7
+ 19 tools across academics, daily life, documents, messaging, and feature discovery.
8
+
7
9
  | Domain | Tools |
8
10
  |---|---|
9
11
  | Districts | `ic_list_districts` |
10
12
  | Students | `ic_list_students` |
11
13
  | Schedule | `ic_get_schedule` |
12
- | Assignments | `ic_list_assignments` (with `missingOnly` filter) |
13
- | Grades | `ic_list_grades` |
14
- | Attendance | `ic_list_attendance` |
15
- | Behavior | `ic_list_behavior` |
16
- | Food service | `ic_list_food_service` |
14
+ | Assignments | `ic_list_assignments` (sectionID server-side; `missingOnly` / date filters client-side) |
15
+ | Grades | `ic_list_grades`, `ic_list_recent_grades` (default 14d window) |
16
+ | School calendar | `ic_list_school_days` |
17
+ | Attendance | `ic_list_attendance` (per-course summary), `ic_list_attendance_events` (individual events with codes + comments) |
18
+ | Behavior | `ic_list_behavior` (FeatureDisabled-aware) |
19
+ | Food service | `ic_list_food_service` (FeatureDisabled-aware) |
17
20
  | Documents | `ic_list_documents`, `ic_download_document` |
18
- | Notifications | `ic_list_messages` (prism notifications), `ic_get_message` (unread count) |
21
+ | Messaging | `ic_list_messages` (3 sources: prism notifications + Messenger 2.0 inbox + portal announcements), `ic_get_message` (fetch parsed HTML body of an inbox message) |
22
+ | Teachers | `ic_list_teachers` (teachers per section + assigned counselors) |
23
+ | Assessments | `ic_list_assessments` (standardized test scores) |
24
+ | Fees | `ic_list_fees` (assignments + surplus balance) |
25
+ | Features | `ic_get_features` (per-enrollment displayOptions flags) |
19
26
 
20
27
  Tools that the harness will gate as write/IO operations: `ic_download_document`.
21
28
 
@@ -31,10 +38,10 @@ IC_PASSWORD=...
31
38
  IC_NAME=Myers Park # optional, defaults to IC_DISTRICT
32
39
  ```
33
40
 
34
- Linked districts (via CUPS SSO) are auto-discovered after login — no extra config needed. If you have truly separate IC instances with different credentials, run two MCP instances.
41
+ Linked districts (via CUPS SSO) are auto-discovered after primary login — a parent with kids in two districts only configures the primary. No extra config needed. If you have truly separate IC instances with different credentials, run two MCP instances.
35
42
 
36
43
  See `.env.example`.
37
44
 
38
45
  ## Status
39
46
 
40
- This project was developed and is maintained by AI (Claude). Use at your own discretion. Unofficial — not affiliated with Infinite Campus.
47
+ Unofficial — not affiliated with Infinite Campus. AI-maintained.
package/dist/bundle.js CHANGED
@@ -29746,11 +29746,11 @@ var McpServer = class {
29746
29746
  }
29747
29747
  return registeredResourceTemplate;
29748
29748
  }
29749
- _createRegisteredPrompt(name, title, description, argsSchema14, callback) {
29749
+ _createRegisteredPrompt(name, title, description, argsSchema15, callback) {
29750
29750
  const registeredPrompt = {
29751
29751
  title,
29752
29752
  description,
29753
- argsSchema: argsSchema14 === void 0 ? void 0 : objectFromShape(argsSchema14),
29753
+ argsSchema: argsSchema15 === void 0 ? void 0 : objectFromShape(argsSchema15),
29754
29754
  callback,
29755
29755
  enabled: true,
29756
29756
  disable: () => registeredPrompt.update({ enabled: false }),
@@ -29776,8 +29776,8 @@ var McpServer = class {
29776
29776
  }
29777
29777
  };
29778
29778
  this._registeredPrompts[name] = registeredPrompt;
29779
- if (argsSchema14) {
29780
- const hasCompletable = Object.values(argsSchema14).some((field) => {
29779
+ if (argsSchema15) {
29780
+ const hasCompletable = Object.values(argsSchema15).some((field) => {
29781
29781
  const inner = field instanceof ZodOptional2 ? field._def?.innerType : field;
29782
29782
  return isCompletable(inner);
29783
29783
  });
@@ -29884,12 +29884,12 @@ var McpServer = class {
29884
29884
  if (typeof rest[0] === "string") {
29885
29885
  description = rest.shift();
29886
29886
  }
29887
- let argsSchema14;
29887
+ let argsSchema15;
29888
29888
  if (rest.length > 1) {
29889
- argsSchema14 = rest.shift();
29889
+ argsSchema15 = rest.shift();
29890
29890
  }
29891
29891
  const cb = rest[0];
29892
- const registeredPrompt = this._createRegisteredPrompt(name, void 0, description, argsSchema14, cb);
29892
+ const registeredPrompt = this._createRegisteredPrompt(name, void 0, description, argsSchema15, cb);
29893
29893
  this.setPromptRequestHandlers();
29894
29894
  this.sendPromptListChanged();
29895
29895
  return registeredPrompt;
@@ -29901,8 +29901,8 @@ var McpServer = class {
29901
29901
  if (this._registeredPrompts[name]) {
29902
29902
  throw new Error(`Prompt ${name} is already registered`);
29903
29903
  }
29904
- const { title, description, argsSchema: argsSchema14 } = config2;
29905
- const registeredPrompt = this._createRegisteredPrompt(name, title, description, argsSchema14, cb);
29904
+ const { title, description, argsSchema: argsSchema15 } = config2;
29905
+ const registeredPrompt = this._createRegisteredPrompt(name, title, description, argsSchema15, cb);
29906
29906
  this.setPromptRequestHandlers();
29907
29907
  this.sendPromptListChanged();
29908
29908
  return registeredPrompt;
@@ -30156,6 +30156,7 @@ var ICClient = class {
30156
30156
  linkedTo = /* @__PURE__ */ new Map();
30157
30157
  // linkedDistrictName → primaryDistrictName
30158
30158
  primaryName;
30159
+ featuresCache = /* @__PURE__ */ new Map();
30159
30160
  constructor(account2) {
30160
30161
  this.accounts.set(account2.name, account2);
30161
30162
  this.primaryName = account2.name;
@@ -30170,6 +30171,22 @@ var ICClient = class {
30170
30171
  linked: this.linkedTo.has(a.name)
30171
30172
  }));
30172
30173
  }
30174
+ /**
30175
+ * Fetch the per-structure displayOptions feature-flag allowlist for a student.
30176
+ * Results are cached per (district, structureID) for the duration of the
30177
+ * session TTL — flags rarely change mid-session and the call costs ~1 RT.
30178
+ */
30179
+ async getFeatures(district, structureID, studentId) {
30180
+ const key = `${district}:${structureID}`;
30181
+ const cached2 = this.featuresCache.get(key);
30182
+ if (cached2 && Date.now() - cached2.fetchedAt < SESSION_TTL_MS) return cached2.data;
30183
+ const data = await this.request(
30184
+ district,
30185
+ `/campus/api/portal/displayOptions/${structureID}?personID=${encodeURIComponent(studentId)}`
30186
+ );
30187
+ this.featuresCache.set(key, { data, fetchedAt: Date.now() });
30188
+ return data;
30189
+ }
30173
30190
  async request(district, path, opts = {}) {
30174
30191
  const account2 = this.accounts.get(district);
30175
30192
  if (!account2) throw new UnknownDistrictError(district, [...this.accounts.keys()]);
@@ -30475,6 +30492,18 @@ async function findStudent(client2, district, studentId) {
30475
30492
  function studentNotFound(studentId) {
30476
30493
  return textContent({ error: "StudentNotFound", studentId });
30477
30494
  }
30495
+ async function checkFeatureDisabled(client2, district, studentId, student, flag, toolName, emptyData = []) {
30496
+ const structureID = student.enrollments?.[0]?.structureID;
30497
+ if (!structureID) return null;
30498
+ try {
30499
+ const features = await client2.getFeatures(district, structureID, studentId);
30500
+ if (features[flag] === false) return featureDisabled(toolName, district, emptyData);
30501
+ return null;
30502
+ } catch (e) {
30503
+ console.error(`[ic] displayOptions check failed for ${district}/${flag}: ${e instanceof Error ? e.message : e}`);
30504
+ return null;
30505
+ }
30506
+ }
30478
30507
  function toArray(value) {
30479
30508
  if (value === null || value === void 0) return [];
30480
30509
  return Array.isArray(value) ? value : [value];
@@ -30625,6 +30654,8 @@ function registerAttendanceTools(server2, client2) {
30625
30654
  const args = argsSchema5.parse(rawArgs);
30626
30655
  const student = await findStudent(client2, args.district, args.studentId);
30627
30656
  if (!student) return studentNotFound(args.studentId);
30657
+ const disabled = await checkFeatureDisabled(client2, args.district, args.studentId, student, "attendance", "attendance");
30658
+ if (disabled) return disabled;
30628
30659
  const enrollments = student.enrollments ?? [];
30629
30660
  const results = [];
30630
30661
  try {
@@ -30665,11 +30696,15 @@ var argsSchema6 = external_exports3.object({
30665
30696
  });
30666
30697
  function registerBehaviorTools(server2, client2) {
30667
30698
  server2.registerTool("ic_list_behavior", {
30668
- description: "List a student's behavior events / referrals. Returns FeatureDisabled if the district has the behavior module turned off.",
30699
+ description: "List a student's behavior events / referrals. Returns FeatureDisabled if the district has the behavior module turned off (detected via displayOptions or a 404 backstop).",
30669
30700
  annotations: { readOnlyHint: true },
30670
30701
  inputSchema: argsSchema6.shape
30671
30702
  }, async (rawArgs) => {
30672
30703
  const args = argsSchema6.parse(rawArgs);
30704
+ const student = await findStudent(client2, args.district, args.studentId);
30705
+ if (!student) return studentNotFound(args.studentId);
30706
+ const disabled = await checkFeatureDisabled(client2, args.district, args.studentId, student, "behavior", "behavior");
30707
+ if (disabled) return disabled;
30673
30708
  const params = new URLSearchParams({ personID: args.studentId });
30674
30709
  if (args.since) params.set("startDate", args.since);
30675
30710
  if (args.until) params.set("endDate", args.until);
@@ -30690,13 +30725,26 @@ var argsSchema7 = external_exports3.object({
30690
30725
  since: external_exports3.string().optional(),
30691
30726
  until: external_exports3.string().optional()
30692
30727
  });
30728
+ var EMPTY_FOOD = { balance: null, transactions: [] };
30693
30729
  function registerFoodServiceTools(server2, client2) {
30694
30730
  server2.registerTool("ic_list_food_service", {
30695
- description: "List a student's lunch balance and recent food-service transactions. Returns FeatureDisabled if the district has the module turned off.",
30731
+ description: "List a student's lunch balance and recent food-service transactions. Returns FeatureDisabled if the district has the module turned off (detected via displayOptions or a 404 backstop).",
30696
30732
  annotations: { readOnlyHint: true },
30697
30733
  inputSchema: argsSchema7.shape
30698
30734
  }, async (rawArgs) => {
30699
30735
  const args = argsSchema7.parse(rawArgs);
30736
+ const student = await findStudent(client2, args.district, args.studentId);
30737
+ if (!student) return studentNotFound(args.studentId);
30738
+ const disabled = await checkFeatureDisabled(
30739
+ client2,
30740
+ args.district,
30741
+ args.studentId,
30742
+ student,
30743
+ "foodService",
30744
+ "foodService",
30745
+ EMPTY_FOOD
30746
+ );
30747
+ if (disabled) return disabled;
30700
30748
  const params = new URLSearchParams({ personID: args.studentId });
30701
30749
  if (args.since) params.set("startDate", args.since);
30702
30750
  if (args.until) params.set("endDate", args.until);
@@ -30704,7 +30752,7 @@ function registerFoodServiceTools(server2, client2) {
30704
30752
  const data = await client2.request(args.district, `/campus/resources/portal/foodService?${params}`);
30705
30753
  return textContent(data);
30706
30754
  } catch (e) {
30707
- if (is404(e)) return featureDisabled("foodService", args.district, { balance: null, transactions: [] });
30755
+ if (is404(e)) return featureDisabled("foodService", args.district, EMPTY_FOOD);
30708
30756
  throw e;
30709
30757
  }
30710
30758
  });
@@ -30839,6 +30887,10 @@ function registerDocumentTools(server2, client2) {
30839
30887
  inputSchema: listArgs2.shape
30840
30888
  }, async (rawArgs) => {
30841
30889
  const args = listArgs2.parse(rawArgs);
30890
+ const student = await findStudent(client2, args.district, args.studentId);
30891
+ if (!student) return studentNotFound(args.studentId);
30892
+ const disabled = await checkFeatureDisabled(client2, args.district, args.studentId, student, "documents", "documents");
30893
+ if (disabled) return disabled;
30842
30894
  try {
30843
30895
  const raw = await client2.request(
30844
30896
  args.district,
@@ -30991,6 +31043,8 @@ function registerAttendanceEventsTools(server2, client2) {
30991
31043
  const args = argsSchema9.parse(rawArgs);
30992
31044
  const student = await findStudent(client2, args.district, args.studentId);
30993
31045
  if (!student) return studentNotFound(args.studentId);
31046
+ const disabled = await checkFeatureDisabled(client2, args.district, args.studentId, student, "attendance", "attendance_events");
31047
+ if (disabled) return disabled;
30994
31048
  const enrollments = student.enrollments ?? [];
30995
31049
  const results = [];
30996
31050
  try {
@@ -31089,6 +31143,7 @@ var DROP_KEYS = /* @__PURE__ */ new Set([
31089
31143
  "mTime",
31090
31144
  "action",
31091
31145
  "personID",
31146
+ "studentPersonID",
31092
31147
  "isKentucky",
31093
31148
  "pairID",
31094
31149
  "pairedEvent"
@@ -31145,6 +31200,8 @@ function registerAssessmentTools(server2, client2) {
31145
31200
  const args = argsSchema12.parse(rawArgs);
31146
31201
  const student = await findStudent(client2, args.district, args.studentId);
31147
31202
  if (!student) return studentNotFound(args.studentId);
31203
+ const disabled = await checkFeatureDisabled(client2, args.district, args.studentId, student, "assessment", "assessments");
31204
+ if (disabled) return disabled;
31148
31205
  const personIDEnc = encodeURIComponent(args.studentId);
31149
31206
  const result = [];
31150
31207
  let feature404 = false;
@@ -31187,7 +31244,7 @@ var argsSchema13 = external_exports3.object({
31187
31244
  });
31188
31245
  function registerFeeTools(server2, client2) {
31189
31246
  server2.registerTool("ic_list_fees", {
31190
- description: "List a student's fee assignments (charges owed) and running balance/surplus. Combines two endpoints: fee assignments and totalSurplus. Returns FeatureDisabled only if both endpoints 404; if only one works, returns that with a note.",
31247
+ description: "List a student's fee assignments (charges owed) and running balance/surplus. Combines two endpoints: fee assignments and totalSurplus. Returns FeatureDisabled only if both endpoints 404; if only one works, returns that side with warning: 'PartialSuccess' and an issues[] explaining which endpoint failed.",
31191
31248
  annotations: { readOnlyHint: true },
31192
31249
  inputSchema: argsSchema13.shape
31193
31250
  }, async (rawArgs) => {
@@ -31211,18 +31268,47 @@ function registerFeeTools(server2, client2) {
31211
31268
  if (!assignments.ok && !surplus.ok) {
31212
31269
  return featureDisabled("fees", args.district, { totalSurplus: null, feeAssignments: [] });
31213
31270
  }
31271
+ const issues = [];
31272
+ if (!assignments.ok) issues.push("feeAssignments endpoint returned 404 (module may be disabled for this district)");
31273
+ if (!surplus.ok) issues.push("totalSurplus endpoint returned 404 (module may be disabled for this district)");
31214
31274
  const response = {
31275
+ ...issues.length > 0 ? { warning: "PartialSuccess" } : {},
31215
31276
  totalSurplus: surplus.ok ? surplus.value : null,
31216
- feeAssignments: assignments.ok ? assignments.value : []
31277
+ feeAssignments: assignments.ok ? assignments.value : [],
31278
+ ...issues.length > 0 ? { issues } : {}
31217
31279
  };
31218
- const notes = [];
31219
- if (!assignments.ok) notes.push("feeAssignments endpoint returned 404 (module may be disabled for this district)");
31220
- if (!surplus.ok) notes.push("totalSurplus endpoint returned 404 (module may be disabled for this district)");
31221
- if (notes.length > 0) response.notes = notes;
31222
31280
  return textContent(response);
31223
31281
  });
31224
31282
  }
31225
31283
 
31284
+ // src/tools/features.ts
31285
+ var argsSchema14 = external_exports3.object({
31286
+ district: external_exports3.string(),
31287
+ studentId: external_exports3.string().describe("Student personID from ic_list_students")
31288
+ });
31289
+ function registerFeaturesTools(server2, client2) {
31290
+ server2.registerTool("ic_get_features", {
31291
+ description: "List the district's displayOptions feature-flag allow-list for each of a student's enrollments. Each enrollment's `features` object is a map of ~90 flag names (attendance, behavior, assessment, documents, grades, schedule, etc.) to booleans. A `false` value means the district has that feature disabled for this enrollment; `true` or missing means it's available. Used internally by other tools to short-circuit disabled features, but exposed here so the LLM can answer capability questions directly.",
31292
+ annotations: { readOnlyHint: true },
31293
+ inputSchema: argsSchema14.shape
31294
+ }, async (rawArgs) => {
31295
+ const args = argsSchema14.parse(rawArgs);
31296
+ const student = await findStudent(client2, args.district, args.studentId);
31297
+ if (!student) return studentNotFound(args.studentId);
31298
+ const result = [];
31299
+ for (const enr of student.enrollments ?? []) {
31300
+ const features = await client2.getFeatures(args.district, enr.structureID, args.studentId);
31301
+ result.push({
31302
+ enrollmentID: enr.enrollmentID,
31303
+ structureID: enr.structureID,
31304
+ schoolName: enr.schoolName,
31305
+ features
31306
+ });
31307
+ }
31308
+ return textContent(result);
31309
+ });
31310
+ }
31311
+
31226
31312
  // src/index.ts
31227
31313
  try {
31228
31314
  const { config: config2 } = await import("dotenv");
@@ -31232,7 +31318,7 @@ try {
31232
31318
  }
31233
31319
  var account = loadAccount();
31234
31320
  var client = new ICClient(account);
31235
- var server = new McpServer({ name: "infinitecampus", version: "2.0.0" });
31321
+ var server = new McpServer({ name: "infinitecampus", version: "2.0.2" });
31236
31322
  registerDistrictTools(server, client);
31237
31323
  registerStudentTools(server, client);
31238
31324
  registerScheduleTools(server, client);
@@ -31249,6 +31335,7 @@ registerRecentGradesTools(server, client);
31249
31335
  registerTeacherTools(server, client);
31250
31336
  registerAssessmentTools(server, client);
31251
31337
  registerFeeTools(server, client);
31338
+ registerFeaturesTools(server, client);
31252
31339
  console.error(`[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})`);
31253
31340
  console.error("[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.");
31254
31341
  var transport = new StdioServerTransport();
package/dist/client.js CHANGED
@@ -6,6 +6,7 @@ export class ICClient {
6
6
  sessions = new Map();
7
7
  linkedTo = new Map(); // linkedDistrictName → primaryDistrictName
8
8
  primaryName;
9
+ featuresCache = new Map();
9
10
  constructor(account) {
10
11
  this.accounts.set(account.name, account);
11
12
  this.primaryName = account.name;
@@ -21,6 +22,20 @@ export class ICClient {
21
22
  linked: this.linkedTo.has(a.name),
22
23
  }));
23
24
  }
25
+ /**
26
+ * Fetch the per-structure displayOptions feature-flag allowlist for a student.
27
+ * Results are cached per (district, structureID) for the duration of the
28
+ * session TTL — flags rarely change mid-session and the call costs ~1 RT.
29
+ */
30
+ async getFeatures(district, structureID, studentId) {
31
+ const key = `${district}:${structureID}`;
32
+ const cached = this.featuresCache.get(key);
33
+ if (cached && Date.now() - cached.fetchedAt < SESSION_TTL_MS)
34
+ return cached.data;
35
+ const data = await this.request(district, `/campus/api/portal/displayOptions/${structureID}?personID=${encodeURIComponent(studentId)}`);
36
+ this.featuresCache.set(key, { data, fetchedAt: Date.now() });
37
+ return data;
38
+ }
24
39
  async request(district, path, opts = {}) {
25
40
  const account = this.accounts.get(district);
26
41
  if (!account)
package/dist/index.js CHANGED
@@ -31,9 +31,10 @@ import { registerRecentGradesTools } from './tools/recent_grades.js';
31
31
  import { registerTeacherTools } from './tools/teachers.js';
32
32
  import { registerAssessmentTools } from './tools/assessments.js';
33
33
  import { registerFeeTools } from './tools/fees.js';
34
+ import { registerFeaturesTools } from './tools/features.js';
34
35
  const account = loadAccount();
35
36
  const client = new ICClient(account);
36
- const server = new McpServer({ name: 'infinitecampus', version: '2.0.0' });
37
+ const server = new McpServer({ name: 'infinitecampus', version: '2.0.2' });
37
38
  registerDistrictTools(server, client);
38
39
  registerStudentTools(server, client);
39
40
  registerScheduleTools(server, client);
@@ -50,6 +51,7 @@ registerRecentGradesTools(server, client);
50
51
  registerTeacherTools(server, client);
51
52
  registerAssessmentTools(server, client);
52
53
  registerFeeTools(server, client);
54
+ registerFeaturesTools(server, client);
53
55
  console.error(`[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})`);
54
56
  console.error('[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.');
55
57
  const transport = new StdioServerTransport();
@@ -19,6 +19,31 @@ export async function findStudent(client, district, studentId) {
19
19
  export function studentNotFound(studentId) {
20
20
  return textContent({ error: 'StudentNotFound', studentId });
21
21
  }
22
+ /**
23
+ * Check a feature flag via the district's displayOptions allow-list for the
24
+ * student's first enrollment's structureID. Returns a FeatureDisabled content
25
+ * block when the flag is explicitly `false`. Returns `null` in every other
26
+ * case (flag is `true`, flag is absent, no enrollments, or the displayOptions
27
+ * call itself fails) — callers then fall through to hit the real endpoint.
28
+ *
29
+ * Non-fatal by design: if the allow-list can't be fetched we don't want to
30
+ * break the tool, since the 404-catch backstop still protects it.
31
+ */
32
+ export async function checkFeatureDisabled(client, district, studentId, student, flag, toolName, emptyData = []) {
33
+ const structureID = student.enrollments?.[0]?.structureID;
34
+ if (!structureID)
35
+ return null;
36
+ try {
37
+ const features = await client.getFeatures(district, structureID, studentId);
38
+ if (features[flag] === false)
39
+ return featureDisabled(toolName, district, emptyData);
40
+ return null;
41
+ }
42
+ catch (e) {
43
+ console.error(`[ic] displayOptions check failed for ${district}/${flag}: ${e instanceof Error ? e.message : e}`);
44
+ return null;
45
+ }
46
+ }
22
47
  /**
23
48
  * Coerce a value to an array. Defensive against IC's prism XML→JSON
24
49
  * serializer which returns a bare object (not a 1-element array) for
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textContent, findStudent, studentNotFound, featureDisabled, is404, toArray } from './_shared.js';
2
+ import { textContent, findStudent, studentNotFound, featureDisabled, is404, toArray, checkFeatureDisabled } from './_shared.js';
3
3
  const argsSchema = z.object({
4
4
  district: z.string(),
5
5
  studentId: z.string().describe('Student personID from ic_list_students'),
@@ -14,6 +14,9 @@ export function registerAssessmentTools(server, client) {
14
14
  const student = await findStudent(client, args.district, args.studentId);
15
15
  if (!student)
16
16
  return studentNotFound(args.studentId);
17
+ const disabled = await checkFeatureDisabled(client, args.district, args.studentId, student, 'assessment', 'assessments');
18
+ if (disabled)
19
+ return disabled;
17
20
  const personIDEnc = encodeURIComponent(args.studentId);
18
21
  const result = [];
19
22
  let feature404 = false;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled, findStudent, studentNotFound, toArray } from './_shared.js';
2
+ import { textContent, is404, featureDisabled, findStudent, studentNotFound, toArray, checkFeatureDisabled } from './_shared.js';
3
3
  const argsSchema = z.object({
4
4
  district: z.string(),
5
5
  studentId: z.string(),
@@ -47,6 +47,9 @@ export function registerAttendanceTools(server, client) {
47
47
  const student = await findStudent(client, args.district, args.studentId);
48
48
  if (!student)
49
49
  return studentNotFound(args.studentId);
50
+ const disabled = await checkFeatureDisabled(client, args.district, args.studentId, student, 'attendance', 'attendance');
51
+ if (disabled)
52
+ return disabled;
50
53
  const enrollments = student.enrollments ?? [];
51
54
  const results = [];
52
55
  try {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled, findStudent, studentNotFound, toArray } from './_shared.js';
2
+ import { textContent, is404, featureDisabled, findStudent, studentNotFound, toArray, checkFeatureDisabled } from './_shared.js';
3
3
  const argsSchema = z.object({
4
4
  district: z.string(),
5
5
  studentId: z.string().describe('Student personID from ic_list_students'),
@@ -48,6 +48,9 @@ export function registerAttendanceEventsTools(server, client) {
48
48
  const student = await findStudent(client, args.district, args.studentId);
49
49
  if (!student)
50
50
  return studentNotFound(args.studentId);
51
+ const disabled = await checkFeatureDisabled(client, args.district, args.studentId, student, 'attendance', 'attendance_events');
52
+ if (disabled)
53
+ return disabled;
51
54
  const enrollments = student.enrollments ?? [];
52
55
  const results = [];
53
56
  try {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled } from './_shared.js';
2
+ import { textContent, is404, featureDisabled, findStudent, studentNotFound, checkFeatureDisabled } from './_shared.js';
3
3
  const argsSchema = z.object({
4
4
  district: z.string(),
5
5
  studentId: z.string(),
@@ -8,11 +8,17 @@ const argsSchema = z.object({
8
8
  });
9
9
  export function registerBehaviorTools(server, client) {
10
10
  server.registerTool('ic_list_behavior', {
11
- description: "List a student's behavior events / referrals. Returns FeatureDisabled if the district has the behavior module turned off.",
11
+ description: "List a student's behavior events / referrals. Returns FeatureDisabled if the district has the behavior module turned off (detected via displayOptions or a 404 backstop).",
12
12
  annotations: { readOnlyHint: true },
13
13
  inputSchema: argsSchema.shape,
14
14
  }, async (rawArgs) => {
15
15
  const args = argsSchema.parse(rawArgs);
16
+ const student = await findStudent(client, args.district, args.studentId);
17
+ if (!student)
18
+ return studentNotFound(args.studentId);
19
+ const disabled = await checkFeatureDisabled(client, args.district, args.studentId, student, 'behavior', 'behavior');
20
+ if (disabled)
21
+ return disabled;
16
22
  const params = new URLSearchParams({ personID: args.studentId });
17
23
  if (args.since)
18
24
  params.set('startDate', args.since);
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled, toArray } from './_shared.js';
2
+ import { textContent, is404, featureDisabled, toArray, findStudent, studentNotFound, checkFeatureDisabled } from './_shared.js';
3
3
  const listArgs = z.object({
4
4
  district: z.string(),
5
5
  studentId: z.string(),
@@ -17,6 +17,12 @@ export function registerDocumentTools(server, client) {
17
17
  inputSchema: listArgs.shape,
18
18
  }, async (rawArgs) => {
19
19
  const args = listArgs.parse(rawArgs);
20
+ const student = await findStudent(client, args.district, args.studentId);
21
+ if (!student)
22
+ return studentNotFound(args.studentId);
23
+ const disabled = await checkFeatureDisabled(client, args.district, args.studentId, student, 'documents', 'documents');
24
+ if (disabled)
25
+ return disabled;
20
26
  try {
21
27
  const raw = await client.request(args.district, `/campus/resources/portal/report/all?personID=${encodeURIComponent(args.studentId)}`);
22
28
  const trimmed = toArray(raw).map((d) => {
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { textContent, findStudent, studentNotFound } from './_shared.js';
3
+ const argsSchema = z.object({
4
+ district: z.string(),
5
+ studentId: z.string().describe('Student personID from ic_list_students'),
6
+ });
7
+ export function registerFeaturesTools(server, client) {
8
+ server.registerTool('ic_get_features', {
9
+ description: "List the district's displayOptions feature-flag allow-list for each of a student's enrollments. Each enrollment's `features` object is a map of ~90 flag names (attendance, behavior, assessment, documents, grades, schedule, etc.) to booleans. A `false` value means the district has that feature disabled for this enrollment; `true` or missing means it's available. Used internally by other tools to short-circuit disabled features, but exposed here so the LLM can answer capability questions directly.",
10
+ annotations: { readOnlyHint: true },
11
+ inputSchema: argsSchema.shape,
12
+ }, async (rawArgs) => {
13
+ const args = argsSchema.parse(rawArgs);
14
+ const student = await findStudent(client, args.district, args.studentId);
15
+ if (!student)
16
+ return studentNotFound(args.studentId);
17
+ const result = [];
18
+ for (const enr of student.enrollments ?? []) {
19
+ const features = await client.getFeatures(args.district, enr.structureID, args.studentId);
20
+ result.push({
21
+ enrollmentID: enr.enrollmentID,
22
+ structureID: enr.structureID,
23
+ schoolName: enr.schoolName,
24
+ features,
25
+ });
26
+ }
27
+ return textContent(result);
28
+ });
29
+ }
@@ -6,7 +6,7 @@ const argsSchema = z.object({
6
6
  });
7
7
  export function registerFeeTools(server, client) {
8
8
  server.registerTool('ic_list_fees', {
9
- description: "List a student's fee assignments (charges owed) and running balance/surplus. Combines two endpoints: fee assignments and totalSurplus. Returns FeatureDisabled only if both endpoints 404; if only one works, returns that with a note.",
9
+ description: "List a student's fee assignments (charges owed) and running balance/surplus. Combines two endpoints: fee assignments and totalSurplus. Returns FeatureDisabled only if both endpoints 404; if only one works, returns that side with warning: 'PartialSuccess' and an issues[] explaining which endpoint failed.",
10
10
  annotations: { readOnlyHint: true },
11
11
  inputSchema: argsSchema.shape,
12
12
  }, async (rawArgs) => {
@@ -27,17 +27,17 @@ export function registerFeeTools(server, client) {
27
27
  if (!assignments.ok && !surplus.ok) {
28
28
  return featureDisabled('fees', args.district, { totalSurplus: null, feeAssignments: [] });
29
29
  }
30
+ const issues = [];
31
+ if (!assignments.ok)
32
+ issues.push('feeAssignments endpoint returned 404 (module may be disabled for this district)');
33
+ if (!surplus.ok)
34
+ issues.push('totalSurplus endpoint returned 404 (module may be disabled for this district)');
30
35
  const response = {
36
+ ...(issues.length > 0 ? { warning: 'PartialSuccess' } : {}),
31
37
  totalSurplus: surplus.ok ? surplus.value : null,
32
38
  feeAssignments: assignments.ok ? assignments.value : [],
39
+ ...(issues.length > 0 ? { issues } : {}),
33
40
  };
34
- const notes = [];
35
- if (!assignments.ok)
36
- notes.push('feeAssignments endpoint returned 404 (module may be disabled for this district)');
37
- if (!surplus.ok)
38
- notes.push('totalSurplus endpoint returned 404 (module may be disabled for this district)');
39
- if (notes.length > 0)
40
- response.notes = notes;
41
41
  return textContent(response);
42
42
  });
43
43
  }
@@ -1,18 +1,25 @@
1
1
  import { z } from 'zod';
2
- import { textContent, is404, featureDisabled } from './_shared.js';
2
+ import { textContent, is404, featureDisabled, findStudent, studentNotFound, checkFeatureDisabled } from './_shared.js';
3
3
  const argsSchema = z.object({
4
4
  district: z.string(),
5
5
  studentId: z.string(),
6
6
  since: z.string().optional(),
7
7
  until: z.string().optional(),
8
8
  });
9
+ const EMPTY_FOOD = { balance: null, transactions: [] };
9
10
  export function registerFoodServiceTools(server, client) {
10
11
  server.registerTool('ic_list_food_service', {
11
- description: "List a student's lunch balance and recent food-service transactions. Returns FeatureDisabled if the district has the module turned off.",
12
+ description: "List a student's lunch balance and recent food-service transactions. Returns FeatureDisabled if the district has the module turned off (detected via displayOptions or a 404 backstop).",
12
13
  annotations: { readOnlyHint: true },
13
14
  inputSchema: argsSchema.shape,
14
15
  }, async (rawArgs) => {
15
16
  const args = argsSchema.parse(rawArgs);
17
+ const student = await findStudent(client, args.district, args.studentId);
18
+ if (!student)
19
+ return studentNotFound(args.studentId);
20
+ const disabled = await checkFeatureDisabled(client, args.district, args.studentId, student, 'foodService', 'foodService', EMPTY_FOOD);
21
+ if (disabled)
22
+ return disabled;
16
23
  const params = new URLSearchParams({ personID: args.studentId });
17
24
  if (args.since)
18
25
  params.set('startDate', args.since);
@@ -24,7 +31,7 @@ export function registerFoodServiceTools(server, client) {
24
31
  }
25
32
  catch (e) {
26
33
  if (is404(e))
27
- return featureDisabled('foodService', args.district, { balance: null, transactions: [] });
34
+ return featureDisabled('foodService', args.district, EMPTY_FOOD);
28
35
  throw e;
29
36
  }
30
37
  });
@@ -6,7 +6,7 @@ const argsSchema = z.object({
6
6
  });
7
7
  // Fields we drop from teacher/counselor contact records (internal IDs, model markers)
8
8
  const DROP_KEYS = new Set([
9
- '_id', '_model', '_hashCode', 'mTime', 'action', 'personID',
9
+ '_id', '_model', '_hashCode', 'mTime', 'action', 'personID', 'studentPersonID',
10
10
  'isKentucky', 'pairID', 'pairedEvent',
11
11
  ]);
12
12
  function trimRecord(raw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Infinite Campus (Campus Parent) MCP server — multi-district read + message/document write",
5
5
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
6
6
  "repository": {
@@ -1,17 +1,19 @@
1
1
  ---
2
2
  name: ic
3
- description: This skill should be used when the user asks about Infinite Campus (Campus Parent portal) data for their kids. Triggers on phrases like "check IC", "Infinite Campus", "what's my kid's grade", "any missing assignments", "school messages", "report card", "lunch balance", or any request about a student's schedule, grades, attendance, behavior, food service, documents, or portal messages. Linked districts are auto-discovered via CUPS SSO.
3
+ description: This skill should be used when the user asks about Infinite Campus (Campus Parent portal) data for their kids. Triggers on phrases like "check IC", "Infinite Campus", "what's my kid's grade", "any missing assignments", "school messages", "report card", "lunch balance", "recent grades", "assessments", "fees", or any request about a student's schedule, grades, assignments, attendance, behavior, food service, documents, messages, teachers, assessments, or fees. Linked districts are auto-discovered via CUPS SSO.
4
4
  ---
5
5
 
6
6
  # infinitecampus-mcp
7
7
 
8
- MCP server for Infinite Campus (Campus Parent portal) — read student schedules, grades, assignments, attendance, behavior, food service, documents, and portal messages. Linked districts are auto-discovered via CUPS SSO after primary login.
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.
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)
12
12
 
13
13
  ## Setup
14
14
 
15
+ Single-account config. Set env vars for your primary IC account; linked districts come from CUPS discovery.
16
+
15
17
  ### Option A — Claude Code (direct MCP, no mcporter)
16
18
 
17
19
  Add to `.mcp.json` in your project or `~/.claude/mcp.json`:
@@ -34,7 +36,7 @@ Add to `.mcp.json` in your project or `~/.claude/mcp.json`:
34
36
  }
35
37
  ```
36
38
 
37
- Linked districts (via CUPS SSO) are auto-discovered after login — no extra config needed. `IC_NAME` is optional and defaults to `IC_DISTRICT`.
39
+ Only `IC_BASE_URL`, `IC_DISTRICT`, `IC_USERNAME`, `IC_PASSWORD` are required. `IC_NAME` is optional (defaults to `IC_DISTRICT`). Linked districts (via CUPS SSO) are auto-discovered after login — a parent with kids in two districts only configures the primary.
38
40
 
39
41
  ### Option B — mcporter
40
42
 
@@ -100,28 +102,39 @@ Every tool except `ic_list_districts` takes `district` as its first arg (the dis
100
102
  ### Academics
101
103
  | Tool | Notes |
102
104
  |------|-------|
103
- | `ic_get_schedule(district, studentId)` | Today's class schedule by default. |
104
- | `ic_list_assignments(district, studentId, courseId?, since?, until?, missingOnly?)` | Pass `missingOnly=true` to see only missing/late work across all courses. |
105
- | `ic_list_grades(district, studentId, termId?)` | Grades summary. Omit `termId` for all terms. |
105
+ | `ic_get_schedule(district, studentId)` | Today's class schedule by default, with section placements. |
106
+ | `ic_list_assignments(district, studentId, courseId?, since?, until?, missingOnly?)` | `sectionID` is the only server-side filter; `since`/`until`/`missingOnly` are applied client-side. Pass `missingOnly=true` to see only missing/late work across all courses. |
107
+ | `ic_list_grades(district, studentId, termId?)` | Term + in-progress grade summary. Omit `termId` for all terms. |
108
+ | `ic_list_recent_grades(district, studentId, since?)` | Recently-scored assignments. Defaults to a 14-day window; pass `since` (YYYY-MM-DD) to widen. |
109
+ | `ic_list_assessments(district, studentId)` | Standardized test scores (state/national/district tests). FeatureDisabled-aware. |
110
+ | `ic_list_teachers(district, studentId)` | Teachers per enrolled section + assigned counselor(s). |
106
111
 
107
112
  ### Daily life
108
113
  | Tool | Notes |
109
114
  |------|-------|
110
- | `ic_list_attendance(district, studentId, since?, until?)` | Absences/tardies with dates. |
115
+ | `ic_list_school_days(district, studentId)` | Instructional calendar with term boundaries. |
116
+ | `ic_list_attendance(district, studentId, since?, until?)` | Per-course attendance summary grouped by term. |
117
+ | `ic_list_attendance_events(district, studentId, since?, until?, excusedOnly?)` | Individual absence/tardy events with codes and human comments. |
111
118
  | `ic_list_behavior(district, studentId, since?, until?)` | Returns a `FeatureDisabled` warning if the district has no behavior module enabled — this is not an error. |
112
119
  | `ic_list_food_service(district, studentId, since?, until?)` | Lunch balance and transactions. Same `FeatureDisabled` fallback as behavior. |
120
+ | `ic_list_fees(district, studentId)` | Fee assignments + surplus/balance. Returns `PartialSuccess` if only one endpoint works, `FeatureDisabled` if both 404. |
113
121
 
114
122
  ### Documents
115
123
  | Tool | Notes |
116
124
  |------|-------|
117
- | `ic_list_documents(district, studentId)` | Metadata only (report cards, transcripts, etc.). Returns `FeatureDisabled` if the district has the documents module turned off. |
118
- | `ic_download_document(district, documentId, destinationPath)` | Writes the PDF to `destinationPath` on disk. **`destinationPath` is required** — confirm the path with the user before calling. Returns `FeatureDisabled` if unavailable. |
125
+ | `ic_list_documents(district, studentId)` | Metadata only (report cards, schedules, transcripts). Each item has a `url` to pass to `ic_download_document`. Returns `FeatureDisabled` if the documents module is off. |
126
+ | `ic_download_document(district, url, destinationPath)` | Writes the document to `destinationPath` on disk. **`destinationPath` is required** — confirm the path with the user before calling. |
127
+
128
+ ### Messaging
129
+ | Tool | Notes |
130
+ |------|-------|
131
+ | `ic_list_messages(district, limit?)` | Combines three sources: **prism notifications** (grade/attendance/assignment alerts), **Messenger 2.0 inbox** (teacher messages, priority announcements like closures), and **portal userNotice** (district banners). `limit` caps prism only. Per-source `error` field if one fails. |
132
+ | `ic_get_message(district, url)` | Fetches and parses the HTML body of an inbox message. Returns `{ subject, date, body, url }`. |
119
133
 
120
- ### Notifications
134
+ ### Features
121
135
  | Tool | Notes |
122
136
  |------|-------|
123
- | `ic_list_messages(district, limit?)` | Portal notifications (district announcements, teacher messages, system alerts) via prism notification system. |
124
- | `ic_get_message(district)` | Unread notification/message count. |
137
+ | `ic_get_features(district, studentId)` | Raw per-enrollment `displayOptions` flags (attendance/behavior/assessment/documents/…). Useful for diagnosing why another tool returned `FeatureDisabled`. |
125
138
 
126
139
  ## Workflows
127
140
 
@@ -129,20 +142,34 @@ Every tool except `ic_list_districts` takes `district` as its first arg (the dis
129
142
  1. `ic_list_districts` → see configured + linked districts
130
143
  2. For each district: `ic_list_students(district)` → collect kids and personIDs
131
144
 
132
- **Are my kids OK?**
133
- 1. `ic_list_assignments(district, studentId, missingOnly=true)` for each kid
134
- 2. `ic_list_grades(district, studentId)` for a current snapshot
135
- 3. `ic_list_attendance(district, studentId, since=<recent>)` for recent absences
145
+ **Is everything OK at school?**
146
+ 1. `ic_list_districts`
147
+ 2. For each student:
148
+ - `ic_list_recent_grades(district, studentId)` last 14 days of scored work
149
+ - `ic_list_attendance_events(district, studentId, excusedOnly=false)` — recent absences/tardies
150
+ - `ic_list_messages(district)` — any alerts from teachers or the district
151
+
152
+ **What got graded this week?**
153
+ - `ic_list_recent_grades(district, studentId, since="YYYY-MM-DD")`
154
+
155
+ **Check upcoming or missing assignments:**
156
+ - `ic_list_assignments(district, studentId, missingOnly=true)` — only the late/missing set
157
+ - Or `ic_list_assignments(district, studentId, since=..., until=...)` for a date window
136
158
 
137
159
  **Today's schedule:**
138
160
  - `ic_get_schedule(district, studentId)` — returns today's classes by default
139
161
 
140
- **Get the report card:**
141
- 1. `ic_list_documents(district, studentId)` find the report card's `documentId`
162
+ **Weather closure or priority announcement?**
163
+ - `ic_list_messages(district)` scan the Messenger 2.0 inbox entries for priority subjects
164
+ - `ic_get_message(district, url)` to read a specific one
165
+
166
+ **Download a report card:**
167
+ 1. `ic_list_documents(district, studentId)` → find the report card's `url`
142
168
  2. Confirm destination path with the user
143
- 3. `ic_download_document(district, studentId, documentId, destinationPath="/Users/.../report-card.pdf")`
169
+ 3. `ic_download_document(district, url, destinationPath="/Users/.../report-card.pdf")`
144
170
 
145
171
  ## Caution
146
172
 
147
- - `ic_download_document` writes a PDF to disk at `destinationPath` — confirm the path with the user; overwrites silently.
148
- - Endpoint behavior varies by district. If `ic_list_behavior`, `ic_list_food_service`, `ic_list_documents`, or `ic_download_document` returns a `FeatureDisabled` warning, that module is simply turned off for the district it's not an error.
173
+ - `ic_download_document` writes to disk at `destinationPath` — confirm the path with the user; overwrites silently.
174
+ - `ic_list_messages` / `ic_get_message` are nominally read-only, but on some district configurations fetching an inbox message or enumerating the list may mark entries as read. Behavior was not confirmable against an empty test inbox.
175
+ - Endpoint behavior varies by district. If `ic_list_behavior`, `ic_list_food_service`, `ic_list_documents`, `ic_list_assessments`, or `ic_list_fees` returns a `FeatureDisabled` warning, that module is simply turned off for the district — it's not an error. `ic_list_fees` may also return `PartialSuccess` when only one of its two sub-endpoints works.