infinitecampus-mcp 0.1.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/dist/client.js ADDED
@@ -0,0 +1,183 @@
1
+ import { writeFile, stat } from 'fs/promises';
2
+ import { dirname } from 'path';
3
+ const SESSION_TTL_MS = 5 * 60 * 60 * 1000; // 5h, slightly under IC's typical 6h
4
+ export class ICClient {
5
+ accounts = new Map();
6
+ sessions = new Map();
7
+ constructor(accounts) {
8
+ for (const a of accounts)
9
+ this.accounts.set(a.name, a);
10
+ }
11
+ listDistricts() {
12
+ return [...this.accounts.values()].map((a) => ({ name: a.name, baseUrl: a.baseUrl }));
13
+ }
14
+ async request(district, path, opts = {}) {
15
+ const account = this.accounts.get(district);
16
+ if (!account)
17
+ throw new UnknownDistrictError(district, [...this.accounts.keys()]);
18
+ await this.ensureSession(account);
19
+ return this.doRequest(account, path, opts, false);
20
+ }
21
+ async ensureSession(account) {
22
+ let s = this.sessions.get(account.name);
23
+ if (s && Date.now() - s.loggedInAt < SESSION_TTL_MS)
24
+ return;
25
+ if (s?.loginInFlight) {
26
+ await s.loginInFlight;
27
+ return;
28
+ }
29
+ if (!s) {
30
+ s = { cookie: '', loggedInAt: 0, loginInFlight: null };
31
+ this.sessions.set(account.name, s);
32
+ }
33
+ s.loginInFlight = this.login(account);
34
+ try {
35
+ await s.loginInFlight;
36
+ }
37
+ finally {
38
+ s.loginInFlight = null;
39
+ }
40
+ }
41
+ async login(account) {
42
+ // Step A: GET login form to capture initial JSESSIONID
43
+ const initRes = await fetch(`${account.baseUrl}/campus/portal/parents/${account.district}.jsp`, { redirect: 'manual' });
44
+ const initCookie = parseSetCookie(initRes.headers.get('set-cookie'));
45
+ // Step B: POST credentials to verify endpoint
46
+ const postRes = await fetch(`${account.baseUrl}/campus/verify.jsp?nonBrowser=true&username=${encodeURIComponent(account.username)}&password=${encodeURIComponent(account.password)}&appName=${encodeURIComponent(account.district)}`, {
47
+ method: 'POST',
48
+ headers: initCookie ? { Cookie: initCookie } : {},
49
+ redirect: 'manual',
50
+ });
51
+ if (postRes.status >= 500)
52
+ throw new PortalUnreachableError(account.name, postRes.status);
53
+ const postCookie = parseSetCookie(postRes.headers.get('set-cookie')) || initCookie;
54
+ if (!postCookie || postRes.status >= 400)
55
+ throw new AuthFailedError(account.name);
56
+ // Mutate the in-map session in place so concurrent callers'
57
+ // references stay live (see ensureSession).
58
+ const session = this.sessions.get(account.name);
59
+ session.cookie = postCookie;
60
+ session.loggedInAt = Date.now();
61
+ }
62
+ async download(district, path, destinationPath, opts = {}) {
63
+ // Pre-flight checks before authenticating, so we fail fast on bad paths
64
+ let destStat = null;
65
+ try {
66
+ destStat = await stat(destinationPath);
67
+ }
68
+ catch { /* not present, ok */ }
69
+ if (destStat?.isDirectory())
70
+ throw new InvalidPathError(destinationPath);
71
+ if (destStat && !opts.overwrite)
72
+ throw new FileExistsError(destinationPath);
73
+ const parent = dirname(destinationPath);
74
+ try {
75
+ await stat(parent);
76
+ }
77
+ catch {
78
+ throw new ParentDirectoryMissingError(parent);
79
+ }
80
+ const account = this.accounts.get(district);
81
+ if (!account)
82
+ throw new UnknownDistrictError(district, [...this.accounts.keys()]);
83
+ await this.ensureSession(account);
84
+ const session = this.sessions.get(account.name);
85
+ const res = await fetch(`${account.baseUrl}${path}`, { headers: { Cookie: session.cookie } });
86
+ if (!res.ok)
87
+ throw new Error(`IC download ${res.status} for ${path}`);
88
+ const buf = new Uint8Array(await res.arrayBuffer());
89
+ await writeFile(destinationPath, buf);
90
+ return {
91
+ path: destinationPath,
92
+ bytes: buf.byteLength,
93
+ contentType: res.headers.get('content-type') ?? 'application/octet-stream',
94
+ };
95
+ }
96
+ async doRequest(account, path, opts, isRetry) {
97
+ const session = this.sessions.get(account.name);
98
+ const res = await fetch(`${account.baseUrl}${path}`, {
99
+ method: opts.method ?? 'GET',
100
+ headers: { Cookie: session.cookie, Accept: 'application/json', ...(opts.headers ?? {}) },
101
+ body: opts.body,
102
+ });
103
+ if (res.status === 401) {
104
+ if (isRetry)
105
+ throw new SessionExpiredError(account.name);
106
+ this.sessions.delete(account.name);
107
+ await this.ensureSession(account);
108
+ return this.doRequest(account, path, opts, true);
109
+ }
110
+ if (res.status >= 500)
111
+ throw new PortalUnreachableError(account.name, res.status);
112
+ if (!res.ok)
113
+ throw new Error(`IC ${res.status} ${res.statusText} for ${path}`);
114
+ const text = await res.text();
115
+ return (text ? JSON.parse(text) : null);
116
+ }
117
+ }
118
+ function parseSetCookie(header) {
119
+ if (!header)
120
+ return '';
121
+ // Take first cookie's name=value, drop attributes
122
+ return header.split(',').map((c) => c.split(';')[0].trim()).join('; ');
123
+ }
124
+ export class UnknownDistrictError extends Error {
125
+ district;
126
+ available;
127
+ constructor(district, available) {
128
+ super(`Unknown district '${district}'. Configured: [${available.join(', ')}]`);
129
+ this.district = district;
130
+ this.available = available;
131
+ this.name = 'UnknownDistrictError';
132
+ }
133
+ }
134
+ export class AuthFailedError extends Error {
135
+ district;
136
+ constructor(district) {
137
+ super(`Login failed for district '${district}'. Check IC_N_USERNAME and IC_N_PASSWORD.`);
138
+ this.district = district;
139
+ this.name = 'AuthFailedError';
140
+ }
141
+ }
142
+ export class PortalUnreachableError extends Error {
143
+ district;
144
+ status;
145
+ constructor(district, status) {
146
+ super(`Portal unreachable for district '${district}' (status ${status})`);
147
+ this.district = district;
148
+ this.status = status;
149
+ this.name = 'PortalUnreachableError';
150
+ }
151
+ }
152
+ export class SessionExpiredError extends Error {
153
+ district;
154
+ constructor(district) {
155
+ super(`Session expired for district '${district}' after re-login retry`);
156
+ this.district = district;
157
+ this.name = 'SessionExpiredError';
158
+ }
159
+ }
160
+ export class InvalidPathError extends Error {
161
+ path;
162
+ constructor(path) {
163
+ super(`InvalidPath: destinationPath must be a filename, not a directory: ${path}`);
164
+ this.path = path;
165
+ this.name = 'InvalidPathError';
166
+ }
167
+ }
168
+ export class ParentDirectoryMissingError extends Error {
169
+ path;
170
+ constructor(path) {
171
+ super(`ParentDirectoryMissing: ${path}`);
172
+ this.path = path;
173
+ this.name = 'ParentDirectoryMissingError';
174
+ }
175
+ }
176
+ export class FileExistsError extends Error {
177
+ path;
178
+ constructor(path) {
179
+ super(`FileExists at ${path}. Pass overwrite:true to replace.`);
180
+ this.path = path;
181
+ this.name = 'FileExistsError';
182
+ }
183
+ }
package/dist/config.js ADDED
@@ -0,0 +1,47 @@
1
+ const FIELDS = ['NAME', 'BASE_URL', 'DISTRICT', 'USERNAME', 'PASSWORD'];
2
+ function readSlot(env, n) {
3
+ const out = {};
4
+ let any = false;
5
+ for (const f of FIELDS) {
6
+ const v = env[`IC_${n}_${f}`];
7
+ if (v && v.length > 0) {
8
+ out[f] = v;
9
+ any = true;
10
+ }
11
+ }
12
+ return { any, fields: out };
13
+ }
14
+ export function loadAccounts(env = process.env) {
15
+ const accounts = [];
16
+ const seenNames = new Map();
17
+ for (let n = 1; n < 1000; n++) {
18
+ const { any, fields } = readSlot(env, n);
19
+ if (!any)
20
+ break;
21
+ const missing = FIELDS.filter((f) => !fields[f]);
22
+ if (missing.length > 0) {
23
+ throw new Error(`Account IC_${n} is incomplete: missing ${missing.join(', ')}. ` +
24
+ `Required vars: ${FIELDS.join(', ')}.`);
25
+ }
26
+ const firstSeenAt = seenNames.get(fields.NAME);
27
+ if (firstSeenAt !== undefined) {
28
+ throw new Error(`Duplicate district name '${fields.NAME}' in IC_${firstSeenAt} and IC_${n}. Names must be unique.`);
29
+ }
30
+ seenNames.set(fields.NAME, n);
31
+ if (!/^https:\/\//.test(fields.BASE_URL)) {
32
+ throw new Error(`IC_${n}_BASE_URL is not a valid https URL: '${fields.BASE_URL}'`);
33
+ }
34
+ accounts.push({
35
+ name: fields.NAME,
36
+ baseUrl: fields.BASE_URL.replace(/\/$/, ''),
37
+ district: fields.DISTRICT,
38
+ username: fields.USERNAME,
39
+ password: fields.PASSWORD,
40
+ });
41
+ }
42
+ if (accounts.length === 0) {
43
+ throw new Error('No Infinite Campus accounts configured. Set IC_1_NAME, IC_1_BASE_URL, ' +
44
+ 'IC_1_DISTRICT, IC_1_USERNAME, IC_1_PASSWORD (and IC_2_*, IC_3_* for more).');
45
+ }
46
+ return accounts;
47
+ }
package/dist/index.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ try {
5
+ const { config } = await import('dotenv');
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ config({ path: join(__dirname, '..', '.env'), override: false });
8
+ }
9
+ catch {
10
+ // dotenv not available — rely on process.env
11
+ }
12
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { loadAccounts } from './config.js';
15
+ import { ICClient } from './client.js';
16
+ import { registerDistrictTools } from './tools/districts.js';
17
+ import { registerStudentTools } from './tools/students.js';
18
+ import { registerScheduleTools } from './tools/schedule.js';
19
+ import { registerAssignmentTools } from './tools/assignments.js';
20
+ import { registerGradeTools } from './tools/grades.js';
21
+ import { registerAttendanceTools } from './tools/attendance.js';
22
+ import { registerBehaviorTools } from './tools/behavior.js';
23
+ import { registerFoodServiceTools } from './tools/foodservice.js';
24
+ import { registerMessageTools } from './tools/messages.js';
25
+ import { registerDocumentTools } from './tools/documents.js';
26
+ const accounts = loadAccounts();
27
+ const client = new ICClient(accounts);
28
+ const server = new McpServer({ name: 'infinitecampus', version: '0.1.0' });
29
+ registerDistrictTools(server, client);
30
+ registerStudentTools(server, client);
31
+ registerScheduleTools(server, client);
32
+ registerAssignmentTools(server, client);
33
+ registerGradeTools(server, client);
34
+ registerAttendanceTools(server, client);
35
+ registerBehaviorTools(server, client);
36
+ registerFoodServiceTools(server, client);
37
+ registerMessageTools(server, client);
38
+ registerDocumentTools(server, client);
39
+ console.error(`[infinitecampus-mcp] Loaded ${accounts.length} district(s): ${accounts.map((a) => a.name).join(', ')}`);
40
+ console.error('[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.');
41
+ const transport = new StdioServerTransport();
42
+ await server.connect(transport);
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ const argsSchema = z.object({
3
+ district: z.string(),
4
+ studentId: z.string(),
5
+ courseId: z.string().optional(),
6
+ since: z.string().describe('YYYY-MM-DD').optional(),
7
+ until: z.string().describe('YYYY-MM-DD').optional(),
8
+ missingOnly: z.boolean().optional(),
9
+ });
10
+ export function registerAssignmentTools(server, client) {
11
+ server.registerTool('ic_list_assignments', {
12
+ description: "List a student's assignments. Filterable by course and date range; missingOnly returns only un-submitted past-due work.",
13
+ annotations: { readOnlyHint: true },
14
+ inputSchema: argsSchema.shape,
15
+ }, async (rawArgs) => {
16
+ const args = argsSchema.parse(rawArgs);
17
+ const params = new URLSearchParams({ personID: args.studentId });
18
+ if (args.courseId)
19
+ params.set('sectionID', args.courseId);
20
+ if (args.since)
21
+ params.set('startDate', args.since);
22
+ if (args.until)
23
+ params.set('endDate', args.until);
24
+ const raw = await client.request(args.district, `/campus/api/portal/parents/assignments?${params}`);
25
+ const data = args.missingOnly ? raw.filter((a) => a.missing) : raw;
26
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
27
+ });
28
+ }
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ const argsSchema = z.object({
3
+ district: z.string(),
4
+ studentId: z.string(),
5
+ since: z.string().describe('YYYY-MM-DD').optional(),
6
+ until: z.string().describe('YYYY-MM-DD').optional(),
7
+ });
8
+ export function registerAttendanceTools(server, client) {
9
+ server.registerTool('ic_list_attendance', {
10
+ description: "List a student's absences and tardies in a date range.",
11
+ annotations: { readOnlyHint: true },
12
+ inputSchema: argsSchema.shape,
13
+ }, async (rawArgs) => {
14
+ const args = argsSchema.parse(rawArgs);
15
+ const params = new URLSearchParams({ personID: args.studentId });
16
+ if (args.since)
17
+ params.set('startDate', args.since);
18
+ if (args.until)
19
+ params.set('endDate', args.until);
20
+ const data = await client.request(args.district, `/campus/api/portal/parents/attendance?${params}`);
21
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
22
+ });
23
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ const argsSchema = z.object({
3
+ district: z.string(),
4
+ studentId: z.string(),
5
+ since: z.string().optional(),
6
+ until: z.string().optional(),
7
+ });
8
+ export function registerBehaviorTools(server, client) {
9
+ server.registerTool('ic_list_behavior', {
10
+ description: "List a student's behavior events / referrals. Returns FeatureDisabled if the district has the behavior module turned off.",
11
+ annotations: { readOnlyHint: true },
12
+ inputSchema: argsSchema.shape,
13
+ }, async (rawArgs) => {
14
+ const args = argsSchema.parse(rawArgs);
15
+ const params = new URLSearchParams({ personID: args.studentId });
16
+ if (args.since)
17
+ params.set('startDate', args.since);
18
+ if (args.until)
19
+ params.set('endDate', args.until);
20
+ try {
21
+ const data = await client.request(args.district, `/campus/api/portal/parents/behavior?${params}`);
22
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
23
+ }
24
+ catch (e) {
25
+ if (e instanceof Error && e.message.startsWith('IC 404 ')) {
26
+ const warn = { warning: 'FeatureDisabled', feature: 'behavior', district: args.district, data: [] };
27
+ return { content: [{ type: 'text', text: JSON.stringify(warn, null, 2) }] };
28
+ }
29
+ throw e;
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,9 @@
1
+ export function registerDistrictTools(server, client) {
2
+ server.registerTool('ic_list_districts', {
3
+ description: 'List Infinite Campus districts configured for this MCP server. Returns names + base URLs (no credentials).',
4
+ annotations: { readOnlyHint: true },
5
+ }, async () => {
6
+ const data = client.listDistricts();
7
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
8
+ });
9
+ }
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ const listArgs = z.object({
3
+ district: z.string(),
4
+ studentId: z.string(),
5
+ });
6
+ const downloadArgs = z.object({
7
+ district: z.string(),
8
+ documentId: z.string().describe('The downloadUrl from ic_list_documents'),
9
+ destinationPath: z.string().describe('Absolute path where the PDF should be written'),
10
+ overwrite: z.boolean().optional(),
11
+ });
12
+ export function registerDocumentTools(server, client) {
13
+ server.registerTool('ic_list_documents', {
14
+ description: "List a student's available documents (report cards, transcripts, etc.). Returns metadata only — use ic_download_document to fetch the file.",
15
+ annotations: { readOnlyHint: true },
16
+ inputSchema: listArgs.shape,
17
+ }, async (rawArgs) => {
18
+ const args = listArgs.parse(rawArgs);
19
+ const data = await client.request(args.district, `/campus/api/portal/parents/documents?personID=${encodeURIComponent(args.studentId)}`);
20
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
21
+ });
22
+ server.registerTool('ic_download_document', {
23
+ description: "Download a student's document (PDF) to disk. documentId is the downloadUrl returned by ic_list_documents.",
24
+ annotations: { destructiveHint: true },
25
+ inputSchema: downloadArgs.shape,
26
+ }, async (rawArgs) => {
27
+ const args = downloadArgs.parse(rawArgs);
28
+ const meta = await client.download(args.district, args.documentId, args.destinationPath, {
29
+ overwrite: args.overwrite ?? false,
30
+ });
31
+ return { content: [{ type: 'text', text: JSON.stringify(meta, null, 2) }] };
32
+ });
33
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ const argsSchema = z.object({
3
+ district: z.string(),
4
+ studentId: z.string(),
5
+ since: z.string().optional(),
6
+ until: z.string().optional(),
7
+ });
8
+ export function registerFoodServiceTools(server, client) {
9
+ server.registerTool('ic_list_food_service', {
10
+ description: "List a student's lunch balance and recent food-service transactions. Returns FeatureDisabled if the district has the module turned off.",
11
+ annotations: { readOnlyHint: true },
12
+ inputSchema: argsSchema.shape,
13
+ }, async (rawArgs) => {
14
+ const args = argsSchema.parse(rawArgs);
15
+ const params = new URLSearchParams({ personID: args.studentId });
16
+ if (args.since)
17
+ params.set('startDate', args.since);
18
+ if (args.until)
19
+ params.set('endDate', args.until);
20
+ try {
21
+ const data = await client.request(args.district, `/campus/api/portal/parents/foodService?${params}`);
22
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
23
+ }
24
+ catch (e) {
25
+ if (e instanceof Error && e.message.startsWith('IC 404 ')) {
26
+ const warn = { warning: 'FeatureDisabled', feature: 'foodService', district: args.district, data: { balance: null, transactions: [] } };
27
+ return { content: [{ type: 'text', text: JSON.stringify(warn, null, 2) }] };
28
+ }
29
+ throw e;
30
+ }
31
+ });
32
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ const argsSchema = z.object({
3
+ district: z.string(),
4
+ studentId: z.string(),
5
+ termId: z.string().optional(),
6
+ });
7
+ export function registerGradeTools(server, client) {
8
+ server.registerTool('ic_list_grades', {
9
+ description: "List a student's term grades and in-progress course grades.",
10
+ annotations: { readOnlyHint: true },
11
+ inputSchema: argsSchema.shape,
12
+ }, async (rawArgs) => {
13
+ const args = argsSchema.parse(rawArgs);
14
+ const params = new URLSearchParams({ personID: args.studentId });
15
+ if (args.termId)
16
+ params.set('termID', args.termId);
17
+ const data = await client.request(args.district, `/campus/api/portal/parents/grades?${params}`);
18
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
19
+ });
20
+ }
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ const listArgs = z.object({
3
+ district: z.string(),
4
+ folder: z.enum(['inbox', 'sent']).optional(),
5
+ page: z.number().int().positive().optional(),
6
+ size: z.number().int().positive().optional(),
7
+ });
8
+ const getArgs = z.object({
9
+ district: z.string(),
10
+ messageId: z.string(),
11
+ });
12
+ const recipientsArgs = z.object({
13
+ district: z.string(),
14
+ studentId: z.string(),
15
+ });
16
+ const sendArgs = z.object({
17
+ district: z.string(),
18
+ studentId: z.string().describe('Student personID; used to validate recipient IDs'),
19
+ recipientIds: z.array(z.string()).min(1),
20
+ subject: z.string().min(1),
21
+ body: z.string().min(1),
22
+ });
23
+ export function registerMessageTools(server, client) {
24
+ server.registerTool('ic_list_messages', {
25
+ description: 'List portal inbox or sent messages (district announcements, teacher notes).',
26
+ annotations: { readOnlyHint: true },
27
+ inputSchema: listArgs.shape,
28
+ }, async (rawArgs) => {
29
+ const args = listArgs.parse(rawArgs);
30
+ const folder = args.folder ?? 'inbox';
31
+ const params = new URLSearchParams({
32
+ folder, page: String(args.page ?? 1), size: String(args.size ?? 50),
33
+ });
34
+ const data = await client.request(args.district, `/campus/api/portal/parents/messages?${params}`);
35
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
36
+ });
37
+ server.registerTool('ic_get_message', {
38
+ description: 'Get a single portal message by ID.',
39
+ annotations: { readOnlyHint: true },
40
+ inputSchema: getArgs.shape,
41
+ }, async (rawArgs) => {
42
+ const args = getArgs.parse(rawArgs);
43
+ const data = await client.request(args.district, `/campus/api/portal/parents/messages/${encodeURIComponent(args.messageId)}`);
44
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
45
+ });
46
+ server.registerTool('ic_list_message_recipients', {
47
+ description: "List people the parent can message about this student (teachers + counselors). IDs returned here are the only valid recipientIds for ic_send_message.",
48
+ annotations: { readOnlyHint: true },
49
+ inputSchema: recipientsArgs.shape,
50
+ }, async (rawArgs) => {
51
+ const args = recipientsArgs.parse(rawArgs);
52
+ const data = await client.request(args.district, `/campus/api/portal/parents/messageRecipients?personID=${encodeURIComponent(args.studentId)}`);
53
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
54
+ });
55
+ server.registerTool('ic_send_message', {
56
+ description: 'Send a portal message to a teacher/counselor about a student. recipientIds MUST come from ic_list_message_recipients for that student.',
57
+ annotations: { destructiveHint: true },
58
+ inputSchema: sendArgs.shape,
59
+ }, async (rawArgs) => {
60
+ const args = sendArgs.parse(rawArgs);
61
+ const valid = await client.request(args.district, `/campus/api/portal/parents/messageRecipients?personID=${encodeURIComponent(args.studentId)}`);
62
+ const validIds = valid.map((v) => v.recipientId);
63
+ const invalidIds = args.recipientIds.filter((id) => !validIds.includes(id));
64
+ if (invalidIds.length > 0) {
65
+ const err = { error: 'InvalidRecipient', invalidIds, validIds };
66
+ return { content: [{ type: 'text', text: JSON.stringify(err, null, 2) }] };
67
+ }
68
+ const data = await client.request(args.district, '/campus/api/portal/parents/messages', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({
72
+ recipientIds: args.recipientIds,
73
+ subject: args.subject,
74
+ body: args.body,
75
+ personID: args.studentId,
76
+ }),
77
+ });
78
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
79
+ });
80
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ const argsSchema = z.object({
3
+ district: z.string().describe('District name from ic_list_districts'),
4
+ studentId: z.string().describe('Student personID from ic_list_students'),
5
+ date: z.string().describe('YYYY-MM-DD; defaults to today').optional(),
6
+ termFilter: z.string().describe('Term name or ID; optional').optional(),
7
+ });
8
+ export function registerScheduleTools(server, client) {
9
+ server.registerTool('ic_get_schedule', {
10
+ description: "Get a student's class schedule for a given date (default: today).",
11
+ annotations: { readOnlyHint: true },
12
+ inputSchema: argsSchema.shape,
13
+ }, async (rawArgs) => {
14
+ const args = argsSchema.parse(rawArgs);
15
+ const date = args.date ?? new Date().toISOString().slice(0, 10);
16
+ const params = new URLSearchParams({ personID: args.studentId, date });
17
+ if (args.termFilter)
18
+ params.set('term', args.termFilter);
19
+ const data = await client.request(args.district, `/campus/api/portal/parents/schedule?${params}`);
20
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
21
+ });
22
+ }
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ const argsSchema = z.object({
3
+ district: z.string().describe('District name from ic_list_districts'),
4
+ });
5
+ export function registerStudentTools(server, client) {
6
+ server.registerTool('ic_list_students', {
7
+ description: 'List students enrolled under the parent account for a given district.',
8
+ annotations: { readOnlyHint: true },
9
+ inputSchema: argsSchema.shape,
10
+ }, async (rawArgs) => {
11
+ const args = argsSchema.parse(rawArgs);
12
+ const data = await client.request(args.district, '/campus/api/portal/parents/students');
13
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
14
+ });
15
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "infinitecampus-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Infinite Campus (Campus Parent) MCP server — multi-district read + message/document write",
5
+ "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/chrischall/infinitecampus-mcp.git"
9
+ },
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "mcp",
13
+ "model-context-protocol",
14
+ "claude",
15
+ "ai",
16
+ "infinite-campus",
17
+ "campus-parent",
18
+ "school",
19
+ "education",
20
+ "k12",
21
+ "grades",
22
+ "attendance",
23
+ "assignments"
24
+ ],
25
+ "type": "module",
26
+ "bin": {
27
+ "infinitecampus-mcp": "dist/index.js"
28
+ },
29
+ "files": ["dist", ".claude-plugin", "skills", ".mcp.json"],
30
+ "scripts": {
31
+ "build": "tsc && npm run bundle",
32
+ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
33
+ "dev": "node --env-file=.env dist/index.js",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.29.0",
39
+ "dotenv": "^17.4.0",
40
+ "zod": "^4.3.6"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^25.5.2",
44
+ "@vitest/coverage-v8": "^4.1.2",
45
+ "esbuild": "^0.28.0",
46
+ "typescript": "^6.0.2",
47
+ "vitest": "^4.1.2"
48
+ }
49
+ }