infinitecampus-mcp 2.3.1 → 2.3.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/auth.js +6 -18
- package/dist/bundle.js +795 -279
- package/dist/client.js +15 -30
- package/dist/config.js +5 -11
- package/dist/index.js +45 -36
- package/dist/tools/_shared.js +8 -2
- package/dist/tools/messages.js +7 -17
- package/package.json +5 -3
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { writeFile, stat } from 'fs/promises';
|
|
2
2
|
import { dirname } from 'path';
|
|
3
|
+
import { parseCookieJar } from '@chrischall/mcp-utils';
|
|
3
4
|
const SESSION_TTL_MS = 5 * 60 * 60 * 1000; // 5h, slightly under IC's typical 6h
|
|
4
5
|
export class ICClient {
|
|
5
6
|
accounts = new Map();
|
|
@@ -342,39 +343,23 @@ export class ICClient {
|
|
|
342
343
|
}
|
|
343
344
|
}
|
|
344
345
|
/**
|
|
345
|
-
* Parse Set-Cookie headers into a deduplicated
|
|
346
|
-
*
|
|
347
|
-
* IC's login response sets ~20 cookies including deletion markers (Max-Age=0).
|
|
348
|
-
* Sending both `appName=` (delete) and `appName=springfield` (set) causes IC to
|
|
349
|
-
* reject requests with "conflicting app name values". This parser:
|
|
350
|
-
* - Filters out cookies with Max-Age=0 (deletion markers)
|
|
351
|
-
* - Deduplicates by name (last value wins)
|
|
352
|
-
* - Extracts XSRF-TOKEN separately for the X-XSRF-TOKEN request header
|
|
346
|
+
* Parse Set-Cookie headers into a deduplicated Cookie header + XSRF token via
|
|
347
|
+
* `parseCookieJar` (see inline comment for the splitter/jar details).
|
|
353
348
|
*/
|
|
354
349
|
function parseSetCookies(headers) {
|
|
350
|
+
// Prefer the structured getSetCookie() split (one cookie per array entry,
|
|
351
|
+
// what real Node fetch Responses provide). Only when it's unavailable do we
|
|
352
|
+
// fall back to naively comma-splitting the joined header — kept identical to
|
|
353
|
+
// the original so the edge-case behavior (commas inside attribute lists) is
|
|
354
|
+
// preserved rather than inheriting parseCookieJar's smarter-but-different
|
|
355
|
+
// splitter. parseCookieJar then does the jar logic: drop deletion markers
|
|
356
|
+
// (Max-Age=0 / expired Expires) and empty values, dedupe by name. That's
|
|
357
|
+
// load-bearing so the synthesized Cookie header never carries both the
|
|
358
|
+
// `appName=` deletion and the real value (IC rejects "conflicting app name").
|
|
355
359
|
const raw = headers.getSetCookie?.() ?? [];
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
for (const entry of headerStrings) {
|
|
360
|
-
// Check for Max-Age=0 → this is a cookie deletion, skip it
|
|
361
|
-
if (/Max-Age=0/i.test(entry))
|
|
362
|
-
continue;
|
|
363
|
-
const nameValue = entry.split(';')[0].trim();
|
|
364
|
-
const eqIdx = nameValue.indexOf('=');
|
|
365
|
-
if (eqIdx < 1)
|
|
366
|
-
continue;
|
|
367
|
-
const name = nameValue.substring(0, eqIdx);
|
|
368
|
-
const value = nameValue.substring(eqIdx + 1);
|
|
369
|
-
// Skip cookies with empty values (clearing instructions)
|
|
370
|
-
if (!value)
|
|
371
|
-
continue;
|
|
372
|
-
jar.set(name, value);
|
|
373
|
-
if (name === 'XSRF-TOKEN')
|
|
374
|
-
xsrfToken = value;
|
|
375
|
-
}
|
|
376
|
-
const cookieHeader = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
377
|
-
return { cookieHeader, xsrfToken };
|
|
360
|
+
const entries = raw.length > 0 ? raw : splitFallback(headers.get('set-cookie'));
|
|
361
|
+
const { cookies, cookieHeader } = parseCookieJar(entries);
|
|
362
|
+
return { cookieHeader, xsrfToken: cookies['XSRF-TOKEN'] ?? '' };
|
|
378
363
|
}
|
|
379
364
|
function splitFallback(header) {
|
|
380
365
|
if (!header)
|
package/dist/config.js
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
1
2
|
/**
|
|
2
3
|
* Read an env var, trim whitespace, and treat as unset if blank or if the value
|
|
3
4
|
* looks like an unsubstituted shell placeholder (e.g. `${FOO}`) — defends
|
|
4
5
|
* against MCP hosts that pass .mcp.json env blocks through unexpanded.
|
|
6
|
+
*
|
|
7
|
+
* Thin adapter over mcp-utils' `readEnvVar` (same defensive semantics), keeping
|
|
8
|
+
* the local `(env, key)` arg order so the call sites below stay unchanged.
|
|
5
9
|
*/
|
|
6
10
|
function readVar(env, key) {
|
|
7
|
-
|
|
8
|
-
if (typeof raw !== 'string')
|
|
9
|
-
return undefined;
|
|
10
|
-
const trimmed = raw.trim();
|
|
11
|
-
if (trimmed.length === 0)
|
|
12
|
-
return undefined;
|
|
13
|
-
if (trimmed === 'undefined' || trimmed === 'null')
|
|
14
|
-
return undefined;
|
|
15
|
-
if (/^\$\{[^}]*\}$/.test(trimmed))
|
|
16
|
-
return undefined;
|
|
17
|
-
return trimmed;
|
|
11
|
+
return readEnvVar(key, { env });
|
|
18
12
|
}
|
|
19
13
|
/**
|
|
20
14
|
* Load the Account from env vars. IC_BASE_URL + IC_DISTRICT are ALWAYS
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { dirname, join } from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
15
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { loadDotenvSafely, runMcp } from '@chrischall/mcp-utils';
|
|
5
|
+
// quiet load of the local .env (no-throw, silent when the file is absent —
|
|
6
|
+
// e.g. inside an mcpb bundle). Path is resolved next to dist/ so the same
|
|
7
|
+
// `..`/.env that the previous inline dotenv call used still applies.
|
|
8
|
+
// override:false keeps real host-provided env winning. stdout stays clean
|
|
9
|
+
// for JSON-RPC (loadDotenvSafely forces dotenv's quiet:true).
|
|
10
|
+
await loadDotenvSafely({
|
|
11
|
+
path: join(dirname(fileURLToPath(import.meta.url)), '..', '.env'),
|
|
12
|
+
override: false,
|
|
13
|
+
});
|
|
16
14
|
import { resolveAuth } from './auth.js';
|
|
17
15
|
import { ICClient } from './client.js';
|
|
18
16
|
import { registerDistrictTools } from './tools/districts.js';
|
|
@@ -32,6 +30,7 @@ import { registerTeacherTools } from './tools/teachers.js';
|
|
|
32
30
|
import { registerAssessmentTools } from './tools/assessments.js';
|
|
33
31
|
import { registerFeeTools } from './tools/fees.js';
|
|
34
32
|
import { registerFeaturesTools } from './tools/features.js';
|
|
33
|
+
const AI_NOTICE = '[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.';
|
|
35
34
|
// Defer config errors so the server can still start cleanly when env vars
|
|
36
35
|
// aren't set (e.g. during the host's install-time smoke test, before the
|
|
37
36
|
// user has filled in user_config OR the user hasn't yet signed into their
|
|
@@ -50,33 +49,43 @@ try {
|
|
|
50
49
|
catch (e) {
|
|
51
50
|
configError = e;
|
|
52
51
|
}
|
|
53
|
-
|
|
52
|
+
// Hand off to mcp-utils' runMcp for the connect + stdio transport + graceful
|
|
53
|
+
// shutdown. We keep the deferred-config-error pattern by deciding the registrar
|
|
54
|
+
// list and banner up here: when configured we wire every
|
|
55
|
+
// register<Domain>Tools(server, client) call; when not, we register no tools so
|
|
56
|
+
// the host's install-time tools/list still succeeds and the user gets an
|
|
57
|
+
// actionable stderr message (banner) instead of a crash loop.
|
|
58
|
+
const COMMON = {
|
|
59
|
+
name: 'infinitecampus',
|
|
60
|
+
version: '2.3.2', // x-release-please-version
|
|
61
|
+
};
|
|
54
62
|
if (account) {
|
|
55
63
|
const client = new ICClient(account, { preloaded });
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
const tools = [
|
|
65
|
+
registerDistrictTools,
|
|
66
|
+
registerStudentTools,
|
|
67
|
+
registerScheduleTools,
|
|
68
|
+
registerAssignmentTools,
|
|
69
|
+
registerGradeTools,
|
|
70
|
+
registerAttendanceTools,
|
|
71
|
+
registerBehaviorTools,
|
|
72
|
+
registerFoodServiceTools,
|
|
73
|
+
registerMessageTools,
|
|
74
|
+
registerDocumentTools,
|
|
75
|
+
registerCalendarTools,
|
|
76
|
+
registerAttendanceEventsTools,
|
|
77
|
+
registerRecentGradesTools,
|
|
78
|
+
registerTeacherTools,
|
|
79
|
+
registerAssessmentTools,
|
|
80
|
+
registerFeeTools,
|
|
81
|
+
registerFeaturesTools,
|
|
82
|
+
];
|
|
73
83
|
const suffix = source === 'fetchproxy' ? ' [via fetchproxy]' : '';
|
|
74
|
-
|
|
84
|
+
const districtLine = `[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})${suffix}`;
|
|
85
|
+
await runMcp({ ...COMMON, deps: client, tools, banner: `${districtLine}\n${AI_NOTICE}` });
|
|
75
86
|
}
|
|
76
87
|
else {
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
const notConfigured = `[infinitecampus-mcp] Not configured: ${configError?.message ?? 'unknown error'}\n` +
|
|
89
|
+
'[infinitecampus-mcp] Server is running with no tools registered. Set the required env vars and reinstall.';
|
|
90
|
+
await runMcp({ ...COMMON, tools: [], banner: `${notConfigured}\n${AI_NOTICE}` });
|
|
79
91
|
}
|
|
80
|
-
console.error('[infinitecampus-mcp] Developed and maintained by AI (Claude). Use at your own discretion.');
|
|
81
|
-
const transport = new StdioServerTransport();
|
|
82
|
-
await server.connect(transport);
|
package/dist/tools/_shared.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
import { textResult } from '@chrischall/mcp-utils';
|
|
2
|
+
/**
|
|
3
|
+
* Wrap a value as an MCP text content block — the standard tool return shape.
|
|
4
|
+
* Thin re-export of mcp-utils' `textResult` so the whole fleet shares one
|
|
5
|
+
* pretty-printed-JSON formatter; the local name is kept to avoid churning
|
|
6
|
+
* every tool call site.
|
|
7
|
+
*/
|
|
2
8
|
export function textContent(data) {
|
|
3
|
-
return
|
|
9
|
+
return textResult(data);
|
|
4
10
|
}
|
|
5
11
|
/** Detect "IC 404 ..." errors thrown by ICClient.request for endpoints that don't exist. */
|
|
6
12
|
export function is404(e) {
|
package/dist/tools/messages.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { extractPlainTextFromHtml } from '@chrischall/mcp-utils/html';
|
|
2
3
|
import { textContent, toArray } from './_shared.js';
|
|
3
4
|
const listArgs = z.object({
|
|
4
5
|
district: z.string(),
|
|
@@ -44,29 +45,18 @@ export function normalizeMessageUrl(input) {
|
|
|
44
45
|
* The HTML has:
|
|
45
46
|
* <title>Message -- <subject></title>
|
|
46
47
|
* body with "Message: <subject>", "Date: MM/DD/YYYY", then the message body.
|
|
47
|
-
* Dependency-free:
|
|
48
|
+
* Dependency-free: title via regex, body via mcp-utils'
|
|
49
|
+
* `extractPlainTextFromHtml` (script/style strip + tag strip + entity decode +
|
|
50
|
+
* whitespace collapse — the shared fleet helper this extractor was hoisted from).
|
|
48
51
|
*/
|
|
49
52
|
export function parseMessageHtml(html, url) {
|
|
50
53
|
// Extract <title>
|
|
51
54
|
const titleMatch = html.match(/<title>([^<]*)<\/title>/i);
|
|
52
55
|
let subject = titleMatch ? titleMatch[1].trim() : '';
|
|
53
56
|
subject = subject.replace(/^Message\s*--\s*/i, '');
|
|
54
|
-
// Strip
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.replace(/<style[\s\S]*?<\/style>/gi, ' ');
|
|
58
|
-
// Strip remaining tags
|
|
59
|
-
text = text.replace(/<[^>]+>/g, ' ');
|
|
60
|
-
// Decode a few common entities
|
|
61
|
-
text = text
|
|
62
|
-
.replace(/ /g, ' ')
|
|
63
|
-
.replace(/&/g, '&')
|
|
64
|
-
.replace(/</g, '<')
|
|
65
|
-
.replace(/>/g, '>')
|
|
66
|
-
.replace(/"/g, '"')
|
|
67
|
-
.replace(/'/g, "'");
|
|
68
|
-
// Collapse whitespace
|
|
69
|
-
text = text.replace(/\s+/g, ' ').trim();
|
|
57
|
+
// Strip the body down to plain text (script/style content removed, tags
|
|
58
|
+
// dropped, entities decoded, whitespace collapsed).
|
|
59
|
+
const text = extractPlainTextFromHtml(html);
|
|
70
60
|
// Extract "Date: MM/DD/YYYY" if present
|
|
71
61
|
const dateMatch = text.match(/Date:\s*(\d{1,2}\/\d{1,2}\/\d{2,4})/);
|
|
72
62
|
const date = dateMatch ? dateMatch[1] : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinitecampus-mcp",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.2",
|
|
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>",
|
|
@@ -42,10 +42,12 @@
|
|
|
42
42
|
"test:watch": "vitest"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@
|
|
46
|
-
"@fetchproxy/
|
|
45
|
+
"@chrischall/mcp-utils": "^0.5.0",
|
|
46
|
+
"@fetchproxy/bootstrap": "^1.0.0",
|
|
47
|
+
"@fetchproxy/server": "^1.0.0",
|
|
47
48
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
49
|
"dotenv": "^17.4.2",
|
|
50
|
+
"node-html-parser": "^7.1.0",
|
|
49
51
|
"zod": "^4.4.3"
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
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.3.
|
|
9
|
+
"version": "2.3.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "infinitecampus-mcp",
|
|
14
|
-
"version": "2.3.
|
|
14
|
+
"version": "2.3.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|