infinitecampus-mcp 2.8.2 → 2.8.3

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.
@@ -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.8.2"
10
+ "version": "2.8.3"
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.8.2",
18
+ "version": "2.8.3",
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.8.2",
4
+ "version": "2.8.3",
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/dist/bundle.js CHANGED
@@ -40097,6 +40097,8 @@ function validateFrame(raw) {
40097
40097
  return validateEncrypted(raw);
40098
40098
  if (t === "pair-pending")
40099
40099
  return validatePairPending(raw);
40100
+ if (t === "hello-rejected")
40101
+ return validateHelloRejected(raw);
40100
40102
  if (t === "extension-disconnected")
40101
40103
  return { type: "extension-disconnected" };
40102
40104
  throw new ProtocolError(`unknown frame type: ${String(t)}`);
@@ -40227,6 +40229,16 @@ function validatePairPending(raw) {
40227
40229
  }
40228
40230
  return { type: "pair-pending", mcpId: raw.mcpId, pairCode: raw.pairCode };
40229
40231
  }
40232
+ function validateHelloRejected(raw) {
40233
+ assertString(raw.mcpId, "hello-rejected.mcpId");
40234
+ if (!isValidMcpId(raw.mcpId))
40235
+ throw new ProtocolError("hello-rejected.mcpId: invalid format");
40236
+ assertString(raw.reason, "hello-rejected.reason");
40237
+ if (raw.reason.length > 200) {
40238
+ throw new ProtocolError("hello-rejected.reason: must be at most 200 characters");
40239
+ }
40240
+ return { type: "hello-rejected", mcpId: raw.mcpId, reason: raw.reason };
40241
+ }
40230
40242
  function validateInnerFrame(raw) {
40231
40243
  assertObject(raw, "inner");
40232
40244
  const t = raw.type;
@@ -40261,6 +40273,11 @@ function validateInnerRequest(raw) {
40261
40273
  if (raw.init.inPage !== void 0) {
40262
40274
  assertBoolean(raw.init.inPage, "inner.init.inPage");
40263
40275
  }
40276
+ if (raw.init.credentials !== void 0) {
40277
+ if (raw.init.credentials !== "include" && raw.init.credentials !== "omit") {
40278
+ throw new ProtocolError("inner.init.credentials: must be 'include' or 'omit'");
40279
+ }
40280
+ }
40264
40281
  return raw;
40265
40282
  }
40266
40283
  if (raw.op === "read_cookies") {
@@ -41041,6 +41058,17 @@ var FetchproxySessionNotReadyError = class extends Error {
41041
41058
  Object.setPrototypeOf(this, new.target.prototype);
41042
41059
  }
41043
41060
  };
41061
+ var FetchproxyHelloRejectedError = class extends Error {
41062
+ mcpId;
41063
+ reason;
41064
+ constructor(info) {
41065
+ super(`fetchproxy: the extension refused the connection for "${info.mcpId}": ${info.reason}. This is the extension's own reason \u2014 it is not a timeout, and retrying unchanged will be refused the same way.`);
41066
+ this.name = "FetchproxyHelloRejectedError";
41067
+ this.mcpId = info.mcpId;
41068
+ this.reason = info.reason;
41069
+ Object.setPrototypeOf(this, new.target.prototype);
41070
+ }
41071
+ };
41044
41072
  async function awaitSessionReady(ready, opts) {
41045
41073
  const ms = opts.timeoutMs ?? SESSION_READY_TIMEOUT_MS;
41046
41074
  if (ms <= 0)
@@ -41066,12 +41094,21 @@ import { join as join2 } from "node:path";
41066
41094
 
41067
41095
  // node_modules/@fetchproxy/server/dist/identity.js
41068
41096
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
41069
- import { join } from "node:path";
41097
+ import { isAbsolute, join } from "node:path";
41070
41098
  import { homedir } from "node:os";
41071
41099
  var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
41072
41100
  var SAFE_SCOPED = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
41101
+ function envIdentityDir() {
41102
+ const raw = process.env.FETCHPROXY_IDENTITY_DIR;
41103
+ if (raw === void 0)
41104
+ return void 0;
41105
+ const dir = raw.trim();
41106
+ if (dir === "" || !isAbsolute(dir))
41107
+ return void 0;
41108
+ return dir;
41109
+ }
41073
41110
  function defaultIdentityDir() {
41074
- return join(homedir(), ".fetchproxy", "identity");
41111
+ return envIdentityDir() ?? join(homedir(), ".fetchproxy", "identity");
41075
41112
  }
41076
41113
  function safeIdentityFileBase(serverName) {
41077
41114
  if (!serverName || serverName === ".." || serverName.includes("..") || !SAFE_PLAIN.test(serverName) && !SAFE_SCOPED.test(serverName)) {
@@ -41079,42 +41116,57 @@ function safeIdentityFileBase(serverName) {
41079
41116
  }
41080
41117
  return serverName.replace(/\//g, "_");
41081
41118
  }
41082
- async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
41083
- const safeFile = safeIdentityFileBase(serverName);
41084
- const path = join(dir, `${safeFile}.json`);
41085
- await mkdir(dir, { recursive: true, mode: 448 });
41086
- try {
41087
- const raw = await readFile(path, "utf8");
41088
- const j2 = JSON.parse(raw);
41089
- return {
41090
- x25519Priv: fromB64(j2.x25519Priv),
41091
- x25519Pub: fromB64(j2.x25519Pub),
41092
- ed25519Priv: fromB64(j2.ed25519Priv),
41093
- ed25519Pub: fromB64(j2.ed25519Pub),
41094
- createdAt: j2.createdAt
41095
- };
41096
- } catch (e) {
41097
- if (e.code !== "ENOENT")
41098
- throw e;
41099
- }
41119
+ function identityFilePath(dir, serverName) {
41120
+ return join(dir, `${safeIdentityFileBase(serverName)}.json`);
41121
+ }
41122
+ async function generateIdentity() {
41100
41123
  const x = await generateX25519();
41101
41124
  const ed = await generateEd25519();
41102
- const id = {
41125
+ return {
41103
41126
  x25519Priv: x.privateKey,
41104
41127
  x25519Pub: x.publicKey,
41105
41128
  ed25519Priv: ed.privateKey,
41106
41129
  ed25519Pub: ed.publicKey,
41107
41130
  createdAt: Date.now()
41108
41131
  };
41109
- const j = {
41132
+ }
41133
+ function serializeIdentity(id) {
41134
+ return JSON.stringify({
41110
41135
  x25519Priv: toB64(id.x25519Priv),
41111
41136
  x25519Pub: toB64(id.x25519Pub),
41112
41137
  ed25519Priv: toB64(id.ed25519Priv),
41113
41138
  ed25519Pub: toB64(id.ed25519Pub),
41114
41139
  createdAt: id.createdAt
41140
+ }, null, 2);
41141
+ }
41142
+ function parseIdentity(text) {
41143
+ const j = JSON.parse(text);
41144
+ return {
41145
+ x25519Priv: fromB64(j.x25519Priv),
41146
+ x25519Pub: fromB64(j.x25519Pub),
41147
+ ed25519Priv: fromB64(j.ed25519Priv),
41148
+ ed25519Pub: fromB64(j.ed25519Pub),
41149
+ createdAt: j.createdAt
41115
41150
  };
41116
- await writeFile(path, JSON.stringify(j, null, 2), { mode: 384 });
41151
+ }
41152
+ async function writeIdentityFile(dir, serverName, id) {
41153
+ const path = identityFilePath(dir, serverName);
41154
+ await mkdir(dir, { recursive: true, mode: 448 });
41155
+ await writeFile(path, serializeIdentity(id), { mode: 384 });
41117
41156
  await chmod(path, 384);
41157
+ return path;
41158
+ }
41159
+ async function loadOrCreateIdentity(serverName, dir = defaultIdentityDir()) {
41160
+ const path = identityFilePath(dir, serverName);
41161
+ await mkdir(dir, { recursive: true, mode: 448 });
41162
+ try {
41163
+ return parseIdentity(await readFile(path, "utf8"));
41164
+ } catch (e) {
41165
+ if (e.code !== "ENOENT")
41166
+ throw e;
41167
+ }
41168
+ const id = await generateIdentity();
41169
+ await writeIdentityFile(dir, serverName, id);
41118
41170
  return id;
41119
41171
  }
41120
41172
 
@@ -41218,6 +41270,9 @@ async function startHost(opts) {
41218
41270
  }
41219
41271
  });
41220
41272
  const ownHello = await buildServerHello({
41273
+ // 2.6.0: tell the extension it can say WHY it refused a hello, instead of
41274
+ // leaving us to time out and guess.
41275
+ accepts: ["hello-rejected"],
41221
41276
  identity: opts.ownIdentity,
41222
41277
  mcpId: opts.ownMcpId,
41223
41278
  serverName: opts.ownServerName,
@@ -41429,6 +41484,16 @@ async function startHost(opts) {
41429
41484
  extensionWs.send(JSON.stringify(frame));
41430
41485
  }
41431
41486
  }
41487
+ if (frame.type === "hello-rejected" && identified === "extension") {
41488
+ if (frame.mcpId === opts.ownMcpId) {
41489
+ rejectOwnSession(new FetchproxyHelloRejectedError({ mcpId: frame.mcpId, reason: frame.reason }));
41490
+ } else {
41491
+ const slot = peers.get(frame.mcpId);
41492
+ if (slot?.helloFrame.accepts?.includes("hello-rejected")) {
41493
+ slot.ws.send(JSON.stringify(frame));
41494
+ }
41495
+ }
41496
+ }
41432
41497
  if (frame.type === "pair-pending" && identified === "extension") {
41433
41498
  if (frame.mcpId === opts.ownMcpId) {
41434
41499
  ownPendingPairCode = frame.pairCode;
@@ -41540,7 +41605,8 @@ async function startPeer(opts) {
41540
41605
  graphqlOps: opts.graphqlOps,
41541
41606
  // 2.5.0: let a host ≥2.5.0 tell us when the extension leaves, so
41542
41607
  // `extensionConnected()` / `sessionLinked()` can go back to false.
41543
- accepts: ["extension-disconnected"]
41608
+ // 2.6.0: `hello-rejected` for the same reason, from the extension.
41609
+ accepts: ["extension-disconnected", "hello-rejected"]
41544
41610
  });
41545
41611
  const sessionNonce = fromB64(hello.sessionNonce);
41546
41612
  ws.send(JSON.stringify(hello));
@@ -41655,6 +41721,9 @@ async function startPeer(opts) {
41655
41721
  }
41656
41722
  return;
41657
41723
  }
41724
+ if (frame.type === "hello-rejected" && frame.mcpId === opts.mcpId) {
41725
+ rejectFirstReady(new FetchproxyHelloRejectedError({ mcpId: frame.mcpId, reason: frame.reason }));
41726
+ }
41658
41727
  if (frame.type === "pair-pending" && frame.mcpId === opts.mcpId) {
41659
41728
  pendingPairCode = frame.pairCode;
41660
41729
  pendingPairListeners.forEach((cb) => cb(frame.pairCode));
@@ -41752,6 +41821,8 @@ function classifyFetchError(error61) {
41752
41821
 
41753
41822
  // node_modules/@fetchproxy/server/dist/classify-bridge-error.js
41754
41823
  function classifyBridgeError(err) {
41824
+ if (err instanceof FetchproxyHelloRejectedError)
41825
+ return "hello_rejected";
41755
41826
  if (err instanceof FetchproxySessionNotReadyError)
41756
41827
  return "session_not_ready";
41757
41828
  if (err instanceof FetchproxyTimeoutError)
@@ -41852,11 +41923,20 @@ var FetchproxyNoTabError = class extends FetchproxyHintedError {
41852
41923
  this.name = "FetchproxyNoTabError";
41853
41924
  }
41854
41925
  };
41926
+ var FetchproxyTabOpeningError = class extends FetchproxyHintedError {
41927
+ constructor(originalError) {
41928
+ super(originalError, "the extension is opening that tab now \u2014 retry in a few seconds. You do not need to open one yourself, and this is not a version problem.");
41929
+ this.name = "FetchproxyTabOpeningError";
41930
+ }
41931
+ };
41855
41932
  var SCOPE_REJECTION = /not in declared/;
41856
- var NO_TAB_REJECTION = /no tab matching (?!.*content script loaded)/;
41933
+ var NO_TAB_REJECTION = /no tab matching (?!.*(content script loaded|one is still opening))/;
41934
+ var TAB_OPENING_REJECTION = /no tab matching .*one is still opening/;
41857
41935
  function protocolErrorFrom(error61) {
41858
41936
  if (SCOPE_REJECTION.test(error61))
41859
41937
  return new FetchproxyScopeError(error61);
41938
+ if (TAB_OPENING_REJECTION.test(error61))
41939
+ return new FetchproxyTabOpeningError(error61);
41860
41940
  if (NO_TAB_REJECTION.test(error61))
41861
41941
  return new FetchproxyNoTabError(error61);
41862
41942
  return new FetchproxyProtocolError(error61);
@@ -42476,9 +42556,9 @@ var FetchproxyServer = class {
42476
42556
  async _fetchOnceWithTimeout(init) {
42477
42557
  const id = this.nextRequestId++;
42478
42558
  const inner = { type: "request", id, op: "fetch", init };
42479
- const pending = new Promise((resolve2) => {
42559
+ const pending = this.guardPending(new Promise((resolve2) => {
42480
42560
  this.pending.set(id, resolve2);
42481
- });
42561
+ }));
42482
42562
  await this.sendInnerFrame(inner);
42483
42563
  const timeoutMs = this.opts.fetchTimeoutMs;
42484
42564
  if (timeoutMs === void 0 || timeoutMs <= 0)
@@ -42525,6 +42605,34 @@ var FetchproxyServer = class {
42525
42605
  * already surface for fetch timeouts). `0`/unset opts out (unbounded),
42526
42606
  * matching the fetch path.
42527
42607
  */
42608
+ /**
42609
+ * Register a verb's pending promise and keep an early rejection from being
42610
+ * fatal.
42611
+ *
42612
+ * Every verb stores its resolver, then AWAITS `sendInnerFrame` before the
42613
+ * caller attaches a handler — and that send does real work (sealing, a
42614
+ * socket write), so it yields across event-loop turns. Anything that drains
42615
+ * the pending maps inside that window rejects a promise nothing is
42616
+ * listening to yet: a refused pairing (`rejectAllPending(pairingErrorMessage
42617
+ * (code))`), an extension disconnect, a `close()`. Under Node's default
42618
+ * `--unhandled-rejections=throw` that ends the PROCESS, so an MCP that
42619
+ * should have reported "approve pair code NNN-NN" dies instead, and each
42620
+ * respawn mints a fresh code nobody can approve (#329).
42621
+ *
42622
+ * The no-op catch is a listener, not a swallow. `.catch()` derives a NEW
42623
+ * promise and leaves this one's rejection intact, so `_withVerbTimeout` and
42624
+ * the caller still see it — the only thing that changes is that the
42625
+ * rejection is never unhandled.
42626
+ *
42627
+ * This is the same hazard `_withVerbTimeout`'s timer already guards against
42628
+ * from the other side, where it drops the resolver "so a late bridge
42629
+ * response doesn't become an unhandled promise that crashes the host".
42630
+ */
42631
+ guardPending(pending) {
42632
+ pending.catch(() => {
42633
+ });
42634
+ return pending;
42635
+ }
42528
42636
  async _withVerbTimeout(pending, pendingMap, id, url2, requestedTimeoutMs) {
42529
42637
  const timeoutMs = this.opts.fetchTimeoutMs;
42530
42638
  if (timeoutMs === void 0 || timeoutMs <= 0)
@@ -42604,9 +42712,9 @@ var FetchproxyServer = class {
42604
42712
  if (opts.subdomain !== void 0)
42605
42713
  assertSubdomainLabel(opts.subdomain);
42606
42714
  const baseDomain = this.resolveBaseDomain(opts.domain);
42607
- const isAbsolute = path.startsWith("http://") || path.startsWith("https://");
42715
+ const isAbsolute2 = path.startsWith("http://") || path.startsWith("https://");
42608
42716
  let host;
42609
- if (isAbsolute) {
42717
+ if (isAbsolute2) {
42610
42718
  try {
42611
42719
  host = new URL(path).host;
42612
42720
  } catch {
@@ -42615,7 +42723,7 @@ var FetchproxyServer = class {
42615
42723
  } else {
42616
42724
  host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
42617
42725
  }
42618
- const url2 = isAbsolute ? path : `https://${host}${path}`;
42726
+ const url2 = isAbsolute2 ? path : `https://${host}${path}`;
42619
42727
  assertUrlInDomains("request url", url2, this.opts.domains);
42620
42728
  let tabUrl = `https://${host}/`;
42621
42729
  if (opts.viaTab !== void 0) {
@@ -42636,7 +42744,11 @@ var FetchproxyServer = class {
42636
42744
  // Spread rather than `inPage: opts.inPage`: the wire's validator treats
42637
42745
  // the key's PRESENCE as the request, and a literal `undefined` would
42638
42746
  // both serialize away inconsistently and read as "asked for" here.
42639
- ...opts.inPage === true ? { inPage: true } : {}
42747
+ ...opts.inPage === true ? { inPage: true } : {},
42748
+ // Spread for the reason `inPage` is: only a deliberate 'omit' goes on
42749
+ // the wire, so an untouched request serialises byte-identically to
42750
+ // every version before this option existed.
42751
+ ...opts.credentials === "omit" ? { credentials: "omit" } : {}
42640
42752
  };
42641
42753
  const result = await this.fetch(init);
42642
42754
  if (!result.ok) {
@@ -42864,9 +42976,9 @@ var FetchproxyServer = class {
42864
42976
  const tabUrl = `https://${host}/`;
42865
42977
  inner = { type: "request", id, op: "read_cookies", init: { tabUrl } };
42866
42978
  }
42867
- const pending = new Promise((resolve2) => {
42979
+ const pending = this.guardPending(new Promise((resolve2) => {
42868
42980
  this.pendingReadCookies.set(id, resolve2);
42869
- });
42981
+ }));
42870
42982
  await this.sendInnerFrame(inner);
42871
42983
  const result = await this._withVerbTimeout(pending, this.pendingReadCookies, id, `https://${host}`);
42872
42984
  if (!result.ok) {
@@ -42923,9 +43035,9 @@ var FetchproxyServer = class {
42923
43035
  ...cookiePath !== void 0 ? { path: cookiePath } : {}
42924
43036
  }
42925
43037
  };
42926
- const pending = new Promise((resolve2, reject) => {
43038
+ const pending = this.guardPending(new Promise((resolve2, reject) => {
42927
43039
  this.pendingWriteCookies.set(id, { resolve: resolve2, reject });
42928
- });
43040
+ }));
42929
43041
  await this.sendInnerFrame(inner);
42930
43042
  return this._withVerbTimeout(pending, this.pendingWriteCookies, id, `https://${host}`);
42931
43043
  }
@@ -42986,9 +43098,9 @@ var FetchproxyServer = class {
42986
43098
  ...opts.pointers ? { pointers: { ...opts.pointers } } : {}
42987
43099
  }
42988
43100
  };
42989
- const pending = new Promise((resolve2, reject) => {
43101
+ const pending = this.guardPending(new Promise((resolve2, reject) => {
42990
43102
  this.pendingStorage.set(id, { resolve: resolve2, reject });
42991
- });
43103
+ }));
42992
43104
  await this.sendInnerFrame(inner);
42993
43105
  return this._withVerbTimeout(pending, this.pendingStorage, id, `https://${host}`);
42994
43106
  }
@@ -43091,9 +43203,9 @@ var FetchproxyServer = class {
43091
43203
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
43092
43204
  }
43093
43205
  };
43094
- const pending = new Promise((resolve2, reject) => {
43206
+ const pending = this.guardPending(new Promise((resolve2, reject) => {
43095
43207
  this.pendingCapture.set(id, { resolve: resolve2, reject });
43096
- });
43208
+ }));
43097
43209
  await this.sendInnerFrame(inner);
43098
43210
  return this._withVerbTimeout(
43099
43211
  pending,
@@ -43184,9 +43296,9 @@ var FetchproxyServer = class {
43184
43296
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
43185
43297
  }
43186
43298
  };
43187
- const pending = new Promise((resolve2, reject) => {
43299
+ const pending = this.guardPending(new Promise((resolve2, reject) => {
43188
43300
  this.pendingRedirect.set(id, { resolve: resolve2, reject });
43189
- });
43301
+ }));
43190
43302
  await this.sendInnerFrame(inner);
43191
43303
  return this._withVerbTimeout(
43192
43304
  pending,
@@ -43274,9 +43386,9 @@ var FetchproxyServer = class {
43274
43386
  ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {}
43275
43387
  }
43276
43388
  };
43277
- const pending = new Promise((resolve2, reject) => {
43389
+ const pending = this.guardPending(new Promise((resolve2, reject) => {
43278
43390
  this.pendingDownload.set(id, { resolve: resolve2, reject });
43279
- });
43391
+ }));
43280
43392
  await this.sendInnerFrame(inner);
43281
43393
  return this._withVerbTimeout(
43282
43394
  pending,
@@ -43332,9 +43444,9 @@ var FetchproxyServer = class {
43332
43444
  keys: [...opts.keys]
43333
43445
  }
43334
43446
  };
43335
- const pending = new Promise((resolve2, reject) => {
43447
+ const pending = this.guardPending(new Promise((resolve2, reject) => {
43336
43448
  this.pendingIdb.set(id, { resolve: resolve2, reject });
43337
- });
43449
+ }));
43338
43450
  await this.sendInnerFrame(inner);
43339
43451
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
43340
43452
  }
@@ -43372,9 +43484,9 @@ var FetchproxyServer = class {
43372
43484
  op: "read_dom",
43373
43485
  init: { origin, names: [...opts.names] }
43374
43486
  };
43375
- const pending = new Promise((resolve2, reject) => {
43487
+ const pending = this.guardPending(new Promise((resolve2, reject) => {
43376
43488
  this.pendingStorage.set(id, { resolve: resolve2, reject });
43377
- });
43489
+ }));
43378
43490
  await this.sendInnerFrame(inner);
43379
43491
  return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
43380
43492
  }
@@ -43418,9 +43530,9 @@ var FetchproxyServer = class {
43418
43530
  ...opts.tabUrl !== void 0 ? { tabUrl: opts.tabUrl } : {}
43419
43531
  }
43420
43532
  };
43421
- const pending = new Promise((resolve2, reject) => {
43533
+ const pending = this.guardPending(new Promise((resolve2, reject) => {
43422
43534
  this.pendingGraphql.set(id, { resolve: resolve2, reject });
43423
- });
43535
+ }));
43424
43536
  await this.sendInnerFrame(inner);
43425
43537
  return this._withVerbTimeout(pending, this.pendingGraphql, id, opts.name);
43426
43538
  }
@@ -43898,6 +44010,8 @@ var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
43898
44010
  "ok",
43899
44011
  "no_credential",
43900
44012
  "credential_rejected",
44013
+ "session_expired",
44014
+ "verification_pending",
43901
44015
  "timeout",
43902
44016
  "http",
43903
44017
  "transport",
@@ -43914,6 +44028,10 @@ function credentialHint(arm, prefix, hostLabel, source2) {
43914
44028
  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.`;
43915
44029
  case "credential_rejected":
43916
44030
  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.`;
44031
+ case "session_expired":
44032
+ return `The credential from '${source2}' is configured, but no session is live \u2014 ${hostLabel} served a sign-in page rather than the data. Sign in again; a cookie-session portal expires these on its own, so this recurs between uses.`;
44033
+ case "verification_pending":
44034
+ return `${hostLabel} is holding the sign-in on a second factor rather than refusing it. Supply the verification code the ACCOUNT HOLDER received \u2014 the credential itself is not the problem, so changing it will not help.`;
43917
44035
  case "timeout":
43918
44036
  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.`;
43919
44037
  case "http":
@@ -43924,12 +44042,102 @@ function credentialHint(arm, prefix, hostLabel, source2) {
43924
44042
  return `Unexpected failure \u2014 see error.message.`;
43925
44043
  }
43926
44044
  }
44045
+ function credentialHealthcheckDescription(hostLabel) {
44046
+ return `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'. Read-only; never returns the credential itself.`;
44047
+ }
44048
+ async function runCredentialHealthcheck(args) {
44049
+ const { prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
44050
+ const probeUrl = probePath ? `https://${hostLabel}${probePath.startsWith("/") ? "" : "/"}${probePath}` : void 0;
44051
+ let probeStarted = 0;
44052
+ let state;
44053
+ try {
44054
+ state = await resolveCredential();
44055
+ } catch (e) {
44056
+ const classified = classifyThrown?.(e);
44057
+ const result2 = {
44058
+ ok: false,
44059
+ // Still false, and still no source: a classification explains WHY
44060
+ // nothing resolved, it does not invent a credential that did.
44061
+ credential: { source: null, resolved: false },
44062
+ // No `url`: nothing was probed, and naming one implies it was tried.
44063
+ probe: { elapsed_ms: 0 },
44064
+ error: {
44065
+ kind: classified?.kind ?? "no_credential",
44066
+ message: truncateErrorMessage(messageOf(e)),
44067
+ ...classified?.detail !== void 0 ? { detail: classified.detail } : {}
44068
+ },
44069
+ // The hint must follow the KIND beside it. Falling back to
44070
+ // `no_credential`'s copy under a classified kind would state a cause
44071
+ // the kind contradicts — the same disagreement this path exists to
44072
+ // remove. So: an inline hint wins; else the classified arm's own
44073
+ // copy (consumer override first); else, for a kind this module has
44074
+ // no copy for, the neutral `unknown` text rather than one that
44075
+ // asserts a cause; else the unclassified `no_credential` default.
44076
+ hint: classified?.hint ?? (isArm(classified?.kind) ? hints?.[classified.kind] ?? credentialHint(classified.kind, prefix, hostLabel, null) : classified !== void 0 ? credentialHint("unknown", prefix, hostLabel, null) : hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null))
44077
+ };
44078
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
44079
+ }
44080
+ const credential = {
44081
+ source: state.source,
44082
+ resolved: state.source !== null,
44083
+ ...state.detail !== void 0 ? { detail: state.detail } : {}
44084
+ };
44085
+ if (!credential.resolved) {
44086
+ const result2 = {
44087
+ ok: false,
44088
+ credential,
44089
+ probe: { elapsed_ms: 0 },
44090
+ error: { kind: "no_credential", message: "no credential source resolved" },
44091
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
44092
+ };
44093
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
44094
+ }
44095
+ let arm = "ok";
44096
+ let error61;
44097
+ let status;
44098
+ let customHint;
44099
+ probeStarted = Date.now();
44100
+ try {
44101
+ await probeFn();
44102
+ } catch (e) {
44103
+ status = statusOf(e);
44104
+ const aborted2 = e instanceof Error && e.name === "AbortError";
44105
+ 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";
44106
+ let kind = arm;
44107
+ let detail;
44108
+ const custom2 = classifyThrown?.(e);
44109
+ if (custom2) {
44110
+ kind = custom2.kind;
44111
+ customHint = custom2.hint;
44112
+ detail = custom2.detail;
44113
+ arm = isArm(kind) ? kind : "unknown";
44114
+ }
44115
+ error61 = {
44116
+ kind,
44117
+ // Redacted AND bounded before it reaches the result: an upstream
44118
+ // failure routinely quotes what it was sent, and a healthcheck is
44119
+ // the tool people paste into a chat when something is broken.
44120
+ message: truncateErrorMessage(messageOf(e)),
44121
+ ...detail !== void 0 ? { detail } : {}
44122
+ };
44123
+ }
44124
+ const result = {
44125
+ ok: error61 === void 0,
44126
+ credential,
44127
+ probe: {
44128
+ ...probeUrl ? { url: probeUrl } : {},
44129
+ elapsed_ms: Date.now() - probeStarted,
44130
+ ...status !== void 0 ? { status } : {}
44131
+ },
44132
+ ...error61 ? { error: error61 } : {},
44133
+ hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
44134
+ };
44135
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
44136
+ }
43927
44137
  function registerCredentialHealthcheckTool(args) {
43928
- const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
43929
- const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
43930
- server.registerTool(`${prefix}_healthcheck`, {
44138
+ args.server.registerTool(`${args.prefix}_healthcheck`, {
43931
44139
  title: "Verify credentials and upstream reachability",
43932
- 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.`,
44140
+ description: `${credentialHealthcheckDescription(args.hostLabel)} Call this when a real tool fails and you want to know which hop broke.`,
43933
44141
  annotations: {
43934
44142
  title: "Verify credentials and upstream reachability",
43935
44143
  readOnlyHint: true,
@@ -43937,92 +44145,7 @@ function registerCredentialHealthcheckTool(args) {
43937
44145
  openWorldHint: true
43938
44146
  },
43939
44147
  inputSchema: {}
43940
- }, async () => {
43941
- let probeStarted = 0;
43942
- let state;
43943
- try {
43944
- state = await resolveCredential();
43945
- } catch (e) {
43946
- const classified = classifyThrown?.(e);
43947
- const result2 = {
43948
- ok: false,
43949
- // Still false, and still no source: a classification explains WHY
43950
- // nothing resolved, it does not invent a credential that did.
43951
- credential: { source: null, resolved: false },
43952
- // No `url`: nothing was probed, and naming one implies it was tried.
43953
- probe: { elapsed_ms: 0 },
43954
- error: {
43955
- kind: classified?.kind ?? "no_credential",
43956
- message: truncateErrorMessage(messageOf(e)),
43957
- ...classified?.detail !== void 0 ? { detail: classified.detail } : {}
43958
- },
43959
- // The hint must follow the KIND beside it. Falling back to
43960
- // `no_credential`'s copy under a classified kind would state a cause
43961
- // the kind contradicts — the same disagreement this path exists to
43962
- // remove. So: an inline hint wins; else the classified arm's own
43963
- // copy (consumer override first); else, for a kind this module has
43964
- // no copy for, the neutral `unknown` text rather than one that
43965
- // asserts a cause; else the unclassified `no_credential` default.
43966
- hint: classified?.hint ?? (isArm(classified?.kind) ? hints?.[classified.kind] ?? credentialHint(classified.kind, prefix, hostLabel, null) : classified !== void 0 ? credentialHint("unknown", prefix, hostLabel, null) : hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null))
43967
- };
43968
- return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
43969
- }
43970
- const credential = {
43971
- source: state.source,
43972
- resolved: state.source !== null,
43973
- ...state.detail !== void 0 ? { detail: state.detail } : {}
43974
- };
43975
- if (!credential.resolved) {
43976
- const result2 = {
43977
- ok: false,
43978
- credential,
43979
- probe: { elapsed_ms: 0 },
43980
- error: { kind: "no_credential", message: "no credential source resolved" },
43981
- hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
43982
- };
43983
- return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
43984
- }
43985
- let arm = "ok";
43986
- let error61;
43987
- let status;
43988
- let customHint;
43989
- probeStarted = Date.now();
43990
- try {
43991
- await probeFn();
43992
- } catch (e) {
43993
- status = statusOf(e);
43994
- const aborted2 = e instanceof Error && e.name === "AbortError";
43995
- 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";
43996
- let kind = arm;
43997
- let detail;
43998
- const custom2 = classifyThrown?.(e);
43999
- if (custom2) {
44000
- kind = custom2.kind;
44001
- customHint = custom2.hint;
44002
- detail = custom2.detail;
44003
- }
44004
- error61 = {
44005
- kind,
44006
- // Redacted AND bounded before it reaches the result: an upstream
44007
- // failure routinely quotes what it was sent, and a healthcheck is
44008
- // the tool people paste into a chat when something is broken.
44009
- message: truncateErrorMessage(messageOf(e)),
44010
- ...detail !== void 0 ? { detail } : {}
44011
- };
44012
- }
44013
- const result = {
44014
- ok: error61 === void 0,
44015
- credential,
44016
- probe: {
44017
- ...probeUrl ? { url: probeUrl } : {},
44018
- elapsed_ms: Date.now() - probeStarted,
44019
- ...status !== void 0 ? { status } : {}
44020
- },
44021
- ...error61 ? { error: error61 } : {},
44022
- hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
44023
- };
44024
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
44025
- });
44148
+ }, async () => runCredentialHealthcheck(args));
44026
44149
  }
44027
44150
 
44028
44151
  // src/config.ts
@@ -44066,7 +44189,7 @@ function loadAccount(env = process.env) {
44066
44189
  // package.json
44067
44190
  var package_default = {
44068
44191
  name: "infinitecampus-mcp",
44069
- version: "2.8.2",
44192
+ version: "2.8.3",
44070
44193
  mcpName: "io.github.chrischall/infinitecampus-mcp",
44071
44194
  description: "Infinite Campus (Campus Parent) MCP server \u2014 multi-district read + message/document write",
44072
44195
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -44111,13 +44234,13 @@ var package_default = {
44111
44234
  typecheck: "tsc -p tsconfig.json --noEmit"
44112
44235
  },
44113
44236
  dependencies: {
44114
- "@chrischall/mcp-utils": "^0.23.2",
44237
+ "@chrischall/mcp-utils": "^0.26.1",
44115
44238
  "@fetchproxy/bootstrap": "^2.2.0",
44116
- "@fetchproxy/server": "^2.2.0",
44117
- "@modelcontextprotocol/sdk": "^1.29.0",
44239
+ "@fetchproxy/server": "^2.10.0",
44240
+ "@modelcontextprotocol/sdk": "^1.30.0",
44118
44241
  dotenv: "^17.4.2",
44119
44242
  "node-html-parser": "^9.0.0",
44120
- zod: "^4.4.3"
44243
+ zod: "^4.5.4"
44121
44244
  },
44122
44245
  devDependencies: {
44123
44246
  "@types/node": "^26.0.0",
@@ -46084,13 +46207,29 @@ try {
46084
46207
  }
46085
46208
  var COMMON = {
46086
46209
  name: "infinitecampus",
46087
- version: "2.8.2"
46210
+ version: "2.8.3"
46088
46211
  // x-release-please-version
46089
46212
  };
46090
46213
  var healthState = { account, source, configError, client: null };
46091
- if (account) {
46092
- const client = new ICClient(account, { refreshSession });
46093
- healthState.client = client;
46214
+ function unconfiguredClient(reason) {
46215
+ const fail = () => {
46216
+ throw new McpToolError(
46217
+ `infinitecampus-mcp is not configured: ${reason?.message ?? "unknown error"}`,
46218
+ {
46219
+ hint: "Set IC_BASE_URL (your portal URL) and IC_DISTRICT (the app-name path segment), plus IC_USERNAME and IC_PASSWORD together if you are not signing in through the browser. Call ic_healthcheck for the resolved state."
46220
+ }
46221
+ );
46222
+ };
46223
+ return new Proxy({}, {
46224
+ get(_target, prop) {
46225
+ if (typeof prop === "symbol" || prop === "then") return void 0;
46226
+ return fail;
46227
+ }
46228
+ });
46229
+ }
46230
+ {
46231
+ const client = account ? new ICClient(account, { refreshSession }) : unconfiguredClient(configError);
46232
+ if (account) healthState.client = client;
46094
46233
  const tools = [
46095
46234
  (server) => registerHealthcheckTools(server, healthState),
46096
46235
  registerDistrictTools,
@@ -46112,16 +46251,8 @@ if (account) {
46112
46251
  registerFeaturesTools
46113
46252
  ];
46114
46253
  const suffix = source === "fetchproxy" ? " [via fetchproxy]" : "";
46115
- const districtLine = `[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})${suffix}`;
46116
- await runMcp({ ...COMMON, deps: client, tools, banner: `${districtLine}
46254
+ const banner = account ? `[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})${suffix}` : `[infinitecampus-mcp] Not configured: ${configError?.message ?? "unknown error"}
46255
+ [infinitecampus-mcp] Every tool is registered and will report this until the required env vars are set. Call ic_healthcheck for the resolved state.`;
46256
+ await runMcp({ ...COMMON, deps: client, tools, banner: `${banner}
46117
46257
  ${AI_NOTICE}` });
46118
- } else {
46119
- const notConfigured = `[infinitecampus-mcp] Not configured: ${configError?.message ?? "unknown error"}
46120
- [infinitecampus-mcp] Server is running with no tools registered. Set the required env vars and reinstall.`;
46121
- await runMcp({
46122
- ...COMMON,
46123
- tools: [(server) => registerHealthcheckTools(server, healthState)],
46124
- banner: `${notConfigured}
46125
- ${AI_NOTICE}`
46126
- });
46127
46258
  }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { dirname, join } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import { loadDotenvSafely, runMcp } from '@chrischall/mcp-utils';
4
+ import { loadDotenvSafely, McpToolError, runMcp } from '@chrischall/mcp-utils';
5
5
  // quiet load of the local .env (no-throw, silent when the file is absent —
6
6
  // e.g. inside an mcpb bundle). Path is resolved next to dist/ so the same
7
7
  // `..`/.env that the previous inline dotenv call used still applies.
@@ -35,7 +35,7 @@ const AI_NOTICE = '[infinitecampus-mcp] Developed and maintained by AI (Claude).
35
35
  // Defer config errors so the server can still start cleanly when env vars
36
36
  // aren't set (e.g. during the host's install-time smoke test, before the
37
37
  // user has filled in user_config OR the user hasn't yet signed into their
38
- // IC portal in the browser). When not configured we register no tools and
38
+ // IC portal in the browser). When not configured every tool still registers and
39
39
  // log a clear stderr message — far better than the previous crash loop.
40
40
  let account = null;
41
41
  let refreshSession;
@@ -51,21 +51,61 @@ catch (e) {
51
51
  configError = e;
52
52
  }
53
53
  // Hand off to mcp-utils' runMcp for the connect + stdio transport + graceful
54
- // shutdown. We keep the deferred-config-error pattern by deciding the registrar
55
- // list and banner up here: when configured we wire every
56
- // register<Domain>Tools(server, client) call; when not, we register no tools so
57
- // the host's install-time tools/list still succeeds and the user gets an
58
- // actionable stderr message (banner) instead of a crash loop.
54
+ // shutdown. The registrar list is the SAME either way; only the client and the
55
+ // banner differ. A tool surface that follows the environment is what this
56
+ // avoids — see `unconfiguredClient`.
59
57
  const COMMON = {
60
58
  name: 'infinitecampus',
61
- version: '2.8.2', // x-release-please-version
59
+ version: '2.8.3', // x-release-please-version
62
60
  };
63
61
  // Shared with the healthcheck so it can report the SAME state the tools see,
64
62
  // including on the unconfigured path where there are no tools at all.
65
63
  const healthState = { account, source, configError, client: null };
66
- if (account) {
67
- const client = new ICClient(account, { refreshSession });
68
- healthState.client = client;
64
+ /**
65
+ * Stands in for the client when configuration is missing, so that every tool
66
+ * registers either way and the failure surfaces WHERE IT IS ACTIONABLE — in the
67
+ * answer to the tool somebody called, not in a tool list they never saw.
68
+ *
69
+ * The surface must not follow the environment. This server is hosted with
70
+ * `perUserChild` + `authFields`, so mcp-host spawns a principal-less child with
71
+ * none of the per-user config for prewarm, auto-update, restart and every
72
+ * admin-portal tool listing — and THAT child's list is what the portal
73
+ * publishes as the connector's surface. It advertised one tool for a connector
74
+ * that has twenty.
75
+ *
76
+ * This continues the reasoning that put the healthcheck on this path rather
77
+ * than reversing it: a server that can explain itself beats one that cannot,
78
+ * and nineteen tools that each name the missing variables explain it better
79
+ * than nineteen absences do. The healthcheck still reports the same state, and
80
+ * `healthState.client` deliberately stays null so it reports "unconfigured"
81
+ * rather than mistaking this stand-in for a live client.
82
+ */
83
+ function unconfiguredClient(reason) {
84
+ const fail = () => {
85
+ throw new McpToolError(`infinitecampus-mcp is not configured: ${reason?.message ?? 'unknown error'}`, {
86
+ hint: 'Set IC_BASE_URL (your portal URL) and IC_DISTRICT (the app-name path segment), ' +
87
+ 'plus IC_USERNAME and IC_PASSWORD together if you are not signing in through the ' +
88
+ 'browser. Call ic_healthcheck for the resolved state.',
89
+ });
90
+ };
91
+ return new Proxy({}, {
92
+ get(_target, prop) {
93
+ // `then` must answer undefined or this object is thenable, and any
94
+ // `await client` would resolve to something other than the client.
95
+ // Symbols (toStringTag, node's inspect) are asked for by the runtime
96
+ // rather than by tool code, so they get the same treatment.
97
+ if (typeof prop === 'symbol' || prop === 'then')
98
+ return undefined;
99
+ return fail;
100
+ },
101
+ });
102
+ }
103
+ {
104
+ const client = account
105
+ ? new ICClient(account, { refreshSession })
106
+ : unconfiguredClient(configError);
107
+ if (account)
108
+ healthState.client = client;
69
109
  const tools = [
70
110
  (server) => registerHealthcheckTools(server, healthState),
71
111
  registerDistrictTools,
@@ -87,19 +127,10 @@ if (account) {
87
127
  registerFeaturesTools,
88
128
  ];
89
129
  const suffix = source === 'fetchproxy' ? ' [via fetchproxy]' : '';
90
- const districtLine = `[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})${suffix}`;
91
- await runMcp({ ...COMMON, deps: client, tools, banner: `${districtLine}\n${AI_NOTICE}` });
92
- }
93
- else {
94
- const notConfigured = `[infinitecampus-mcp] Not configured: ${configError?.message ?? 'unknown error'}\n` +
95
- '[infinitecampus-mcp] Server is running with no tools registered. Set the required env vars and reinstall.';
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
- });
130
+ const banner = account
131
+ ? `[infinitecampus-mcp] District: ${account.name} (${account.baseUrl})${suffix}`
132
+ : `[infinitecampus-mcp] Not configured: ${configError?.message ?? 'unknown error'}\n` +
133
+ '[infinitecampus-mcp] Every tool is registered and will report this until the ' +
134
+ 'required env vars are set. Call ic_healthcheck for the resolved state.';
135
+ await runMcp({ ...COMMON, deps: client, tools, banner: `${banner}\n${AI_NOTICE}` });
105
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinitecampus-mcp",
3
- "version": "2.8.2",
3
+ "version": "2.8.3",
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,13 +45,13 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^0.23.2",
48
+ "@chrischall/mcp-utils": "^0.26.1",
49
49
  "@fetchproxy/bootstrap": "^2.2.0",
50
- "@fetchproxy/server": "^2.2.0",
51
- "@modelcontextprotocol/sdk": "^1.29.0",
50
+ "@fetchproxy/server": "^2.10.0",
51
+ "@modelcontextprotocol/sdk": "^1.30.0",
52
52
  "dotenv": "^17.4.2",
53
53
  "node-html-parser": "^9.0.0",
54
- "zod": "^4.4.3"
54
+ "zod": "^4.5.4"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^26.0.0",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/infinitecampus-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.8.2",
9
+ "version": "2.8.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "infinitecampus-mcp",
14
- "version": "2.8.2",
14
+ "version": "2.8.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },