myhotlunchbox-mcp 0.2.1 → 0.4.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for My Hot Lunchbox — order school lunches, manage students, and track payments via natural language",
10
- "version": "0.2.1"
10
+ "version": "0.4.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "My Hot Lunchbox",
16
16
  "source": "./",
17
17
  "description": "MCP server for My Hot Lunchbox — school lunch calendar, ordering, and payments. Signs in server-side with the parent account credentials.",
18
- "version": "0.2.1",
18
+ "version": "0.4.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "myhotlunchbox-mcp",
3
3
  "displayName": "My Hot Lunchbox",
4
- "version": "0.2.1",
4
+ "version": "0.4.0",
5
5
  "description": "MCP server for My Hot Lunchbox — order school lunches, manage students, and track payments via natural language",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -38,10 +38,12 @@ still works; the configuration error surfaces on the first tool call.
38
38
 
39
39
  ## Tools
40
40
 
41
- 34 tools, all prefixed `mhlb_`. All 20 read tools are verified live against a real parent account (`node scripts/verify-reads.mjs`); the 14 write tools are not — see below.
41
+ 35 tools, all prefixed `mhlb_`. All 20 read tools are verified live against a real parent account (`node scripts/verify-reads.mjs`); the 14 write tools are not — see below.
42
42
 
43
43
  **Account** — `mhlb_whoami`, `mhlb_session_reset`
44
44
 
45
+ **Health** — `mhlb_healthcheck` (is this connector working? reports whether the credential resolved, whether My Hot Lunchbox accepted it, and what to fix — unlike `mhlb_whoami`, which throws instead of answering)
46
+
45
47
  **Students** — `mhlb_list_students`, `mhlb_get_student_form`,
46
48
  `mhlb_new_student_form`, `mhlb_create_student`, `mhlb_update_student`,
47
49
  `mhlb_delete_student`
package/dist/auth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpToolError, truncateErrorMessage } from '@chrischall/mcp-utils';
2
2
  import { TokenManager } from '@chrischall/mcp-utils/session';
3
3
  import { API_PREFIX, OAUTH_SCOPE } from './config.js';
4
+ import { createTokenCache, reportCacheWriteFailure } from './token-cache.js';
4
5
  /** Fallback lifetime when the server omits `expires_in`. */
5
6
  const DEFAULT_TOKEN_LIFETIME_S = 3600;
6
7
  /**
@@ -132,13 +133,24 @@ export class MhlbAuth {
132
133
  if (this.loginInFlight)
133
134
  return this.loginInFlight;
134
135
  this.loginInFlight = (async () => {
135
- const body = await this.passwordLogin();
136
136
  const manager = new TokenManager({
137
- initial: {
138
- accessToken: this.assertAccessToken(body),
139
- refreshToken: body.refresh_token,
140
- expiresAt: MhlbAuth.expiryOf(body),
137
+ // The FUNCTION form, so the cache is consulted before the password
138
+ // grant runs at all — the eager object form skips persistence, which
139
+ // would mean a cache that is written and never read.
140
+ initial: async () => {
141
+ const body = await this.passwordLogin();
142
+ return {
143
+ accessToken: this.assertAccessToken(body),
144
+ refreshToken: body.refresh_token,
145
+ expiresAt: MhlbAuth.expiryOf(body),
146
+ };
141
147
  },
148
+ persistence: createTokenCache() ?? undefined,
149
+ onPersistError: reportCacheWriteFailure,
150
+ // `refresh` below already recovers from a revoked refresh token by
151
+ // falling back to a full login, so the library's own re-mint-on-revoked
152
+ // recovery would only add a third attempt after both have failed.
153
+ isRefreshRevoked: () => false,
142
154
  refresh: async (refreshToken) => {
143
155
  let next;
144
156
  try {
package/dist/bundle.js CHANGED
@@ -2984,7 +2984,7 @@ var require_compile = __commonJS({
2984
2984
  const schOrFunc = root.refs[ref];
2985
2985
  if (schOrFunc)
2986
2986
  return schOrFunc;
2987
- let _sch = resolve2.call(this, root, ref);
2987
+ let _sch = resolve3.call(this, root, ref);
2988
2988
  if (_sch === void 0) {
2989
2989
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
2990
2990
  const { schemaId } = this.opts;
@@ -3011,7 +3011,7 @@ var require_compile = __commonJS({
3011
3011
  function sameSchemaEnv(s1, s2) {
3012
3012
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3013
3013
  }
3014
- function resolve2(root, ref) {
3014
+ function resolve3(root, ref) {
3015
3015
  let sch;
3016
3016
  while (typeof (sch = this.refs[ref]) == "string")
3017
3017
  ref = sch;
@@ -3836,7 +3836,7 @@ var require_fast_uri = __commonJS({
3836
3836
  }
3837
3837
  return uri;
3838
3838
  }
3839
- function resolve2(baseURI, relativeURI, options) {
3839
+ function resolve3(baseURI, relativeURI, options) {
3840
3840
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3841
3841
  const {
3842
3842
  parsed: baseParsed,
@@ -4198,7 +4198,7 @@ var require_fast_uri = __commonJS({
4198
4198
  var fastUri = {
4199
4199
  SCHEMES,
4200
4200
  normalize,
4201
- resolve: resolve2,
4201
+ resolve: resolve3,
4202
4202
  resolveComponent,
4203
4203
  equal,
4204
4204
  serialize,
@@ -29145,7 +29145,7 @@ var Protocol = class {
29145
29145
  return;
29146
29146
  }
29147
29147
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
29148
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
29148
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
29149
29149
  options?.signal?.throwIfAborted();
29150
29150
  }
29151
29151
  } catch (error51) {
@@ -29162,7 +29162,7 @@ var Protocol = class {
29162
29162
  */
29163
29163
  request(request, resultSchema, options) {
29164
29164
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
29165
- return new Promise((resolve2, reject) => {
29165
+ return new Promise((resolve3, reject) => {
29166
29166
  const earlyReject = (error51) => {
29167
29167
  reject(error51);
29168
29168
  };
@@ -29240,7 +29240,7 @@ var Protocol = class {
29240
29240
  if (!parseResult.success) {
29241
29241
  reject(parseResult.error);
29242
29242
  } else {
29243
- resolve2(parseResult.data);
29243
+ resolve3(parseResult.data);
29244
29244
  }
29245
29245
  } catch (error51) {
29246
29246
  reject(error51);
@@ -29501,12 +29501,12 @@ var Protocol = class {
29501
29501
  }
29502
29502
  } catch {
29503
29503
  }
29504
- return new Promise((resolve2, reject) => {
29504
+ return new Promise((resolve3, reject) => {
29505
29505
  if (signal.aborted) {
29506
29506
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
29507
29507
  return;
29508
29508
  }
29509
- const timeoutId = setTimeout(resolve2, interval);
29509
+ const timeoutId = setTimeout(resolve3, interval);
29510
29510
  signal.addEventListener("abort", () => {
29511
29511
  clearTimeout(timeoutId);
29512
29512
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -30597,7 +30597,7 @@ var McpServer = class {
30597
30597
  let task = createTaskResult.task;
30598
30598
  const pollInterval = task.pollInterval ?? 5e3;
30599
30599
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
30600
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
30600
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
30601
30601
  const updatedTask = await extra.taskStore.getTask(taskId);
30602
30602
  if (!updatedTask) {
30603
30603
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -31261,12 +31261,12 @@ var StdioServerTransport = class {
31261
31261
  this.onclose?.();
31262
31262
  }
31263
31263
  send(message) {
31264
- return new Promise((resolve2) => {
31264
+ return new Promise((resolve3) => {
31265
31265
  const json2 = serializeMessage(message);
31266
31266
  if (this._stdout.write(json2)) {
31267
- resolve2();
31267
+ resolve3();
31268
31268
  } else {
31269
- this._stdout.once("drain", resolve2);
31269
+ this._stdout.once("drain", resolve3);
31270
31270
  }
31271
31271
  });
31272
31272
  }
@@ -31343,6 +31343,11 @@ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
31343
31343
  return redacted;
31344
31344
  return `${redacted.slice(0, max)}\u2026 [truncated]`;
31345
31345
  }
31346
+ function messageOf(err) {
31347
+ if (err instanceof Error)
31348
+ return err.message;
31349
+ return String(err);
31350
+ }
31346
31351
 
31347
31352
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
31348
31353
  function textResult(data) {
@@ -31440,6 +31445,20 @@ function readEnvVar(key, opts = {}) {
31440
31445
  }
31441
31446
  return opts.default;
31442
31447
  }
31448
+ var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
31449
+ var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
31450
+ function parseBoolEnv(key, opts = {}) {
31451
+ const fallback = opts.default ?? false;
31452
+ const raw = readEnvVar(key, { env: opts.env });
31453
+ if (raw === void 0)
31454
+ return fallback;
31455
+ const token = raw.toLowerCase();
31456
+ if (TRUE_TOKENS.has(token))
31457
+ return true;
31458
+ if (FALSE_TOKENS.has(token))
31459
+ return false;
31460
+ return fallback;
31461
+ }
31443
31462
  async function loadDotenvSafely(opts = {}) {
31444
31463
  try {
31445
31464
  const mod = await import(
@@ -31457,6 +31476,34 @@ async function loadDotenvSafely(opts = {}) {
31457
31476
  }
31458
31477
  }
31459
31478
 
31479
+ // node_modules/@chrischall/mcp-utils/dist/http/index.js
31480
+ var RateLimitedError = class extends Error {
31481
+ status = 429;
31482
+ constructor(service) {
31483
+ super(`Rate limited (429) by ${service} after retries.`);
31484
+ this.name = "RateLimitedError";
31485
+ Object.setPrototypeOf(this, new.target.prototype);
31486
+ }
31487
+ };
31488
+ var RequestTimeoutError = class extends Error {
31489
+ timeoutMs;
31490
+ constructor(service, timeoutMs) {
31491
+ super(`Request to ${service} timed out after ${timeoutMs}ms.`);
31492
+ this.name = "RequestTimeoutError";
31493
+ this.timeoutMs = timeoutMs;
31494
+ Object.setPrototypeOf(this, new.target.prototype);
31495
+ }
31496
+ };
31497
+ var ApiError = class extends Error {
31498
+ status;
31499
+ constructor(status, message) {
31500
+ super(message);
31501
+ this.name = "ApiError";
31502
+ this.status = status;
31503
+ Object.setPrototypeOf(this, new.target.prototype);
31504
+ }
31505
+ };
31506
+
31460
31507
  // node_modules/@chrischall/mcp-utils/dist/zod/index.js
31461
31508
  var PositiveInt = external_exports.number().int().positive();
31462
31509
  var NonNegInt = external_exports.number().int().nonnegative();
@@ -31485,57 +31532,313 @@ function toolAnnotations(opts = {}) {
31485
31532
  }
31486
31533
 
31487
31534
  // node_modules/@chrischall/mcp-utils/dist/session/index.js
31535
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
31536
+ import { dirname, join, resolve } from "node:path";
31537
+ import { homedir } from "node:os";
31538
+ import { randomBytes, createHmac } from "node:crypto";
31539
+ function bindingDigest(secret, salt) {
31540
+ return createHmac("sha256", salt).update(secret).digest("hex").slice(0, 32);
31541
+ }
31542
+ function isEnvelope(raw) {
31543
+ return raw !== null && typeof raw === "object" && raw.v === 1 && "state" in raw;
31544
+ }
31545
+ var StatePersistenceError = class extends Error {
31546
+ cause;
31547
+ constructor(cause) {
31548
+ super(cause instanceof Error ? cause.message : String(cause));
31549
+ this.name = "StatePersistenceError";
31550
+ this.cause = cause;
31551
+ Object.setPrototypeOf(this, new.target.prototype);
31552
+ }
31553
+ };
31554
+ function createFileStatePersistence(opts) {
31555
+ const { filePath, validate } = opts;
31556
+ const secret = opts.boundTo;
31557
+ return {
31558
+ load() {
31559
+ if (!existsSync(filePath))
31560
+ return null;
31561
+ try {
31562
+ const raw = JSON.parse(readFileSync(filePath, "utf8"));
31563
+ let state;
31564
+ if (isEnvelope(raw)) {
31565
+ const b = raw.boundTo;
31566
+ if (secret === void 0) {
31567
+ if (b !== void 0)
31568
+ return null;
31569
+ } else {
31570
+ if (b === void 0)
31571
+ return null;
31572
+ if (bindingDigest(secret, b.salt) !== b.digest)
31573
+ return null;
31574
+ }
31575
+ state = raw.state;
31576
+ } else {
31577
+ if (secret !== void 0)
31578
+ return null;
31579
+ state = raw;
31580
+ }
31581
+ if (validate !== void 0)
31582
+ return validate(state);
31583
+ return state;
31584
+ } catch {
31585
+ return null;
31586
+ }
31587
+ },
31588
+ save(state) {
31589
+ const dir = dirname(filePath);
31590
+ const tmp = `${filePath}.tmp-${randomBytes(6).toString("hex")}`;
31591
+ const dirExisted = existsSync(dir);
31592
+ try {
31593
+ mkdirSync(dir, { recursive: true, mode: 448 });
31594
+ if (!dirExisted)
31595
+ chmodSync(dir, 448);
31596
+ let boundTo;
31597
+ if (secret !== void 0) {
31598
+ const salt = randomBytes(16).toString("hex");
31599
+ boundTo = { salt, digest: bindingDigest(secret, salt) };
31600
+ }
31601
+ const envelope = { v: 1, ...boundTo !== void 0 ? { boundTo } : {}, state };
31602
+ writeFileSync(tmp, JSON.stringify(envelope, null, 2), { mode: 384 });
31603
+ chmodSync(tmp, 384);
31604
+ renameSync(tmp, filePath);
31605
+ chmodSync(filePath, 384);
31606
+ } catch (err) {
31607
+ try {
31608
+ if (existsSync(tmp))
31609
+ unlinkSync(tmp);
31610
+ } catch {
31611
+ }
31612
+ throw err;
31613
+ }
31614
+ },
31615
+ clear() {
31616
+ try {
31617
+ if (existsSync(filePath))
31618
+ unlinkSync(filePath);
31619
+ } catch {
31620
+ }
31621
+ }
31622
+ };
31623
+ }
31624
+ function homeOf(env) {
31625
+ return readEnvVar("HOME", { env }) ?? homedir();
31626
+ }
31627
+ function resolveStateDir(opts = {}) {
31628
+ const env = opts.env;
31629
+ return join(readEnvVar("MCP_DATA_DIR", { env }) ?? homeOf(env), opts.subdir ?? "");
31630
+ }
31631
+ function resolveStateFile(opts) {
31632
+ const override = opts.envVar !== void 0 ? readEnvVar(opts.envVar, { env: opts.env }) : void 0;
31633
+ if (override !== void 0) {
31634
+ if (override === "~")
31635
+ return homeOf(opts.env);
31636
+ if (override.startsWith("~/"))
31637
+ return join(homeOf(opts.env), override.slice(2));
31638
+ return resolve(override);
31639
+ }
31640
+ const dirOpts = { env: opts.env };
31641
+ if (opts.subdir !== void 0)
31642
+ dirOpts.subdir = opts.subdir;
31643
+ return join(resolveStateDir(dirOpts), opts.fileName);
31644
+ }
31488
31645
  var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
31646
+ function defaultIsRefreshRevoked(err) {
31647
+ if (err instanceof RateLimitedError || err instanceof RequestTimeoutError)
31648
+ return false;
31649
+ if (err instanceof ApiError && err.status >= 500)
31650
+ return false;
31651
+ return true;
31652
+ }
31653
+ function isBearerTokens(raw) {
31654
+ if (raw === null || typeof raw !== "object")
31655
+ return false;
31656
+ const t = raw;
31657
+ if (typeof t.accessToken !== "string" || t.accessToken === "")
31658
+ return false;
31659
+ if (typeof t.expiresAt !== "number" || !Number.isFinite(t.expiresAt))
31660
+ return false;
31661
+ return t.refreshToken === void 0 || typeof t.refreshToken === "string";
31662
+ }
31489
31663
  var TokenManager = class {
31490
- accessToken;
31491
- refreshToken;
31492
- expiresAt;
31664
+ tokens;
31665
+ bootstrapFn;
31493
31666
  refreshFn;
31494
31667
  skewMs;
31668
+ persistence;
31669
+ now;
31670
+ isRefreshRevokedFn;
31671
+ onPersistErrorFn;
31495
31672
  inFlight;
31673
+ bootstrapInFlight;
31674
+ /**
31675
+ * Persistence is consulted at most once per process. Without this the
31676
+ * revoked-token recovery below re-reads the SAME rejected record — `clear()`
31677
+ * is optional on {@link StatePersistence} and its failures are swallowed, so
31678
+ * recovery must not depend on it. After the first read the in-memory tokens
31679
+ * (or their deliberate absence) are the truth.
31680
+ */
31681
+ persistenceRead = false;
31496
31682
  constructor(opts) {
31497
- this.accessToken = opts.initial.accessToken;
31498
- this.refreshToken = opts.initial.refreshToken;
31499
- this.expiresAt = opts.initial.expiresAt;
31683
+ if (typeof opts.initial === "function") {
31684
+ this.bootstrapFn = opts.initial;
31685
+ } else {
31686
+ this.tokens = { ...opts.initial };
31687
+ }
31500
31688
  this.refreshFn = opts.refresh;
31501
31689
  this.skewMs = opts.skewMs ?? TOKEN_REFRESH_SKEW_MS;
31690
+ this.persistence = opts.persistence;
31691
+ this.now = opts.now ?? Date.now;
31692
+ this.isRefreshRevokedFn = opts.isRefreshRevoked ?? defaultIsRefreshRevoked;
31693
+ this.onPersistErrorFn = opts.onPersistError;
31502
31694
  }
31503
31695
  /** Whether the token is within the skew window of (or past) expiry. */
31504
31696
  needsRefresh() {
31505
- return Date.now() >= this.expiresAt - this.skewMs;
31697
+ if (this.tokens === void 0)
31698
+ return false;
31699
+ return this.now() >= this.tokens.expiresAt - this.skewMs;
31700
+ }
31701
+ /**
31702
+ * A stored token is worth using when it is still valid, OR when it carries a
31703
+ * refresh token — an expired-but-refreshable token still saves the login,
31704
+ * which is the expensive half.
31705
+ */
31706
+ isUsable(t) {
31707
+ return this.now() < t.expiresAt - this.skewMs || t.refreshToken !== void 0;
31708
+ }
31709
+ /** Read persisted tokens, guarding shape and usability. Never throws. */
31710
+ async loadPersisted() {
31711
+ if (this.persistence === void 0 || this.persistenceRead)
31712
+ return null;
31713
+ this.persistenceRead = true;
31714
+ try {
31715
+ const raw = await this.persistence.load();
31716
+ if (!isBearerTokens(raw) || !this.isUsable(raw))
31717
+ return null;
31718
+ return raw;
31719
+ } catch {
31720
+ return null;
31721
+ }
31722
+ }
31723
+ /**
31724
+ * Write tokens. Silent by default (a lost write costs a future login, not this
31725
+ * request); throws a {@link StatePersistenceError} when `onPersistError` does.
31726
+ */
31727
+ async persist(t) {
31728
+ if (this.persistence === void 0)
31729
+ return;
31730
+ try {
31731
+ await this.persistence.save(t);
31732
+ } catch (err) {
31733
+ if (this.onPersistErrorFn === void 0)
31734
+ return;
31735
+ try {
31736
+ this.onPersistErrorFn(err);
31737
+ } catch (hookErr) {
31738
+ throw new StatePersistenceError(hookErr);
31739
+ }
31740
+ }
31741
+ }
31742
+ /** Discard persisted tokens (a refresh they could not satisfy). Never throws. */
31743
+ async clearPersisted() {
31744
+ if (this.persistence?.clear === void 0)
31745
+ return;
31746
+ try {
31747
+ await this.persistence.clear();
31748
+ } catch {
31749
+ }
31750
+ }
31751
+ /** The current tokens, single-flighting the bootstrap if there are none. */
31752
+ ensureTokens() {
31753
+ if (this.tokens !== void 0)
31754
+ return Promise.resolve(this.tokens);
31755
+ if (this.bootstrapInFlight === void 0) {
31756
+ this.bootstrapInFlight = this.runBootstrap().finally(() => {
31757
+ this.bootstrapInFlight = void 0;
31758
+ });
31759
+ }
31760
+ return this.bootstrapInFlight;
31761
+ }
31762
+ /** One bootstrap attempt: persisted tokens if usable, else the login. */
31763
+ async runBootstrap() {
31764
+ const stored = await this.loadPersisted();
31765
+ if (stored !== null) {
31766
+ this.tokens = stored;
31767
+ return stored;
31768
+ }
31769
+ if (this.bootstrapFn === void 0) {
31770
+ throw new Error("TokenManager: no tokens and no bootstrap function to mint them.");
31771
+ }
31772
+ const fresh = await this.bootstrapFn();
31773
+ this.tokens = fresh;
31774
+ try {
31775
+ await this.persist(fresh);
31776
+ } catch (err) {
31777
+ throw err instanceof StatePersistenceError ? err.cause : err;
31778
+ }
31779
+ return fresh;
31506
31780
  }
31507
31781
  /**
31508
31782
  * Single-flight refresh. Concurrent callers share one in-flight promise; it is
31509
31783
  * cleared on settle (success or failure) so a subsequent refresh can proceed.
31510
31784
  */
31511
31785
  refreshNow() {
31512
- if (!this.inFlight) {
31513
- const rt = this.refreshToken;
31514
- if (rt === void 0) {
31515
- return Promise.reject(new Error("TokenManager: cannot refresh \u2014 no refresh token is available."));
31516
- }
31517
- this.inFlight = (async () => {
31518
- const tok = await this.refreshFn(rt);
31519
- this.accessToken = tok.accessToken;
31520
- if (tok.refreshToken !== void 0 && tok.refreshToken !== "") {
31521
- this.refreshToken = tok.refreshToken;
31522
- }
31523
- this.expiresAt = tok.expiresAt;
31524
- })().finally(() => {
31786
+ if (this.inFlight === void 0) {
31787
+ this.inFlight = this.runRefresh().finally(() => {
31525
31788
  this.inFlight = void 0;
31526
31789
  });
31527
31790
  }
31528
31791
  return this.inFlight;
31529
31792
  }
31793
+ /** One refresh attempt against the current refresh token. */
31794
+ async runRefresh() {
31795
+ const current = this.tokens ?? await this.ensureTokens();
31796
+ const rt = current.refreshToken;
31797
+ if (rt === void 0) {
31798
+ throw new Error("TokenManager: cannot refresh \u2014 no refresh token is available.");
31799
+ }
31800
+ const tok = await this.refreshFn(rt);
31801
+ this.tokens = {
31802
+ accessToken: tok.accessToken,
31803
+ // Rotation is optional: keep the current refresh token when none comes back.
31804
+ refreshToken: tok.refreshToken !== void 0 && tok.refreshToken !== "" ? tok.refreshToken : rt,
31805
+ expiresAt: tok.expiresAt
31806
+ };
31807
+ await this.persist(this.tokens);
31808
+ }
31809
+ /**
31810
+ * Recover from a refresh the current credential could not satisfy — commonly
31811
+ * a refresh token restored from a previous process and revoked since. Without
31812
+ * a bootstrap to fall back on this is terminal; with one, re-minting beats
31813
+ * staying broken forever. Shared so the two entry points cannot diverge.
31814
+ */
31815
+ async reBootstrap(err) {
31816
+ if (err instanceof StatePersistenceError)
31817
+ throw err.cause;
31818
+ if (this.bootstrapFn === void 0)
31819
+ throw err;
31820
+ if (!this.isRefreshRevokedFn(err))
31821
+ throw err;
31822
+ this.tokens = void 0;
31823
+ await this.clearPersisted();
31824
+ return this.ensureTokens();
31825
+ }
31530
31826
  /** Get a valid access token, refreshing proactively inside the skew window. */
31531
31827
  async getAccessToken() {
31532
- if (this.needsRefresh())
31533
- await this.refreshNow();
31534
- return this.accessToken;
31828
+ let tokens = this.tokens ?? await this.ensureTokens();
31829
+ if (this.needsRefresh()) {
31830
+ try {
31831
+ await this.refreshNow();
31832
+ } catch (err) {
31833
+ return (await this.reBootstrap(err)).accessToken;
31834
+ }
31835
+ tokens = this.tokens ?? tokens;
31836
+ }
31837
+ return tokens.accessToken;
31535
31838
  }
31536
- /** Current absolute expiry (epoch ms). */
31839
+ /** Current absolute expiry (epoch ms), or `0` before the first bootstrap. */
31537
31840
  getExpiresAt() {
31538
- return this.expiresAt;
31841
+ return this.tokens?.expiresAt ?? 0;
31539
31842
  }
31540
31843
  /**
31541
31844
  * Run an authenticated request with reactive 401-replay. `call` receives a
@@ -31553,9 +31856,14 @@ var TokenManager = class {
31553
31856
  const usedToken = await this.getAccessToken();
31554
31857
  let res = await call(usedToken);
31555
31858
  if (res.status === 401) {
31556
- if (this.accessToken === usedToken)
31557
- await this.refreshNow();
31558
- res = await call(this.accessToken);
31859
+ if (this.tokens?.accessToken === usedToken) {
31860
+ try {
31861
+ await this.refreshNow();
31862
+ } catch (err) {
31863
+ await this.reBootstrap(err);
31864
+ }
31865
+ }
31866
+ res = await call(this.tokens?.accessToken ?? usedToken);
31559
31867
  }
31560
31868
  return res;
31561
31869
  }
@@ -31574,6 +31882,41 @@ function loadConfig(env = process.env) {
31574
31882
  };
31575
31883
  }
31576
31884
 
31885
+ // src/token-cache.ts
31886
+ function tokenCachePath(env = process.env) {
31887
+ return resolveStateFile({
31888
+ env,
31889
+ envVar: "MYHOTLUNCHBOX_TOKEN_FILE",
31890
+ subdir: ".myhotlunchbox-mcp",
31891
+ fileName: "token.json"
31892
+ });
31893
+ }
31894
+ function isTokens(raw) {
31895
+ if (raw === null || typeof raw !== "object") return false;
31896
+ const t = raw;
31897
+ return typeof t.accessToken === "string" && t.accessToken !== "" && typeof t.expiresAt === "number" && (t.refreshToken === void 0 || typeof t.refreshToken === "string");
31898
+ }
31899
+ function createTokenCache(env = process.env) {
31900
+ if (!parseBoolEnv("MYHOTLUNCHBOX_TOKEN_CACHE", { env, default: true })) return null;
31901
+ const username = readEnvVar("MYHOTLUNCHBOX_USERNAME", { env });
31902
+ const password = readEnvVar("MYHOTLUNCHBOX_PASSWORD", { env });
31903
+ if (username === void 0 || password === void 0) return null;
31904
+ return createFileStatePersistence({
31905
+ filePath: tokenCachePath(env),
31906
+ // Joined on a NUL, written as an escape rather than a literal byte: a
31907
+ // password may contain spaces, so a space-joined pair could collide with a
31908
+ // different pair by shifting the boundary between the two halves.
31909
+ boundTo: [username.trim().toLowerCase(), password].join("\0"),
31910
+ validate: (raw) => isTokens(raw) ? raw : null
31911
+ });
31912
+ }
31913
+ function reportCacheWriteFailure(err) {
31914
+ const detail = err instanceof Error ? err.message : String(err);
31915
+ console.error(
31916
+ `[myhotlunchbox-mcp] could not cache the OAuth tokens (${detail}); continuing without the cache \u2014 every restart will log in again until this is fixed.`
31917
+ );
31918
+ }
31919
+
31577
31920
  // src/auth.ts
31578
31921
  var DEFAULT_TOKEN_LIFETIME_S = 3600;
31579
31922
  function scrubCredentials(text, secrets) {
@@ -31681,13 +32024,24 @@ var MhlbAuth = class _MhlbAuth {
31681
32024
  if (this.manager) return this.manager;
31682
32025
  if (this.loginInFlight) return this.loginInFlight;
31683
32026
  this.loginInFlight = (async () => {
31684
- const body = await this.passwordLogin();
31685
32027
  const manager = new TokenManager({
31686
- initial: {
31687
- accessToken: this.assertAccessToken(body),
31688
- refreshToken: body.refresh_token,
31689
- expiresAt: _MhlbAuth.expiryOf(body)
32028
+ // The FUNCTION form, so the cache is consulted before the password
32029
+ // grant runs at all — the eager object form skips persistence, which
32030
+ // would mean a cache that is written and never read.
32031
+ initial: async () => {
32032
+ const body = await this.passwordLogin();
32033
+ return {
32034
+ accessToken: this.assertAccessToken(body),
32035
+ refreshToken: body.refresh_token,
32036
+ expiresAt: _MhlbAuth.expiryOf(body)
32037
+ };
31690
32038
  },
32039
+ persistence: createTokenCache() ?? void 0,
32040
+ onPersistError: reportCacheWriteFailure,
32041
+ // `refresh` below already recovers from a revoked refresh token by
32042
+ // falling back to a full login, so the library's own re-mint-on-revoked
32043
+ // recovery would only add a third attempt after both have failed.
32044
+ isRefreshRevoked: () => false,
31691
32045
  refresh: async (refreshToken) => {
31692
32046
  let next;
31693
32047
  try {
@@ -31891,7 +32245,7 @@ var MhlbClient = class {
31891
32245
  };
31892
32246
 
31893
32247
  // src/version.ts
31894
- var VERSION = "0.2.1";
32248
+ var VERSION = "0.4.0";
31895
32249
 
31896
32250
  // src/tools/_shared.ts
31897
32251
  function preview(action, request, notes) {
@@ -32420,12 +32774,12 @@ function checkoutBody(args) {
32420
32774
  }
32421
32775
 
32422
32776
  // src/tools/reports.ts
32423
- import { mkdirSync, writeFileSync } from "node:fs";
32424
- import { isAbsolute, join, resolve } from "node:path";
32777
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
32778
+ import { isAbsolute, join as join2, resolve as resolve2 } from "node:path";
32425
32779
  var MAX_INLINE_BYTES = 75e4;
32426
32780
  function outputDir(env = process.env) {
32427
32781
  const configured = readEnvVar("MYHOTLUNCHBOX_OUTPUT_DIR", { env });
32428
- return configured ? resolve(configured) : process.cwd();
32782
+ return configured ? resolve2(configured) : process.cwd();
32429
32783
  }
32430
32784
  function* candidateNames(filename) {
32431
32785
  const dot = filename.lastIndexOf(".");
@@ -32440,9 +32794,9 @@ function writeWithoutClobbering(dir, filename, bytes) {
32440
32794
  for (const name of candidateNames(filename)) {
32441
32795
  if (attempts >= MAX_NAME_ATTEMPTS) break;
32442
32796
  attempts += 1;
32443
- const candidate = join(dir, name);
32797
+ const candidate = join2(dir, name);
32444
32798
  try {
32445
- writeFileSync(candidate, bytes, { flag: "wx" });
32799
+ writeFileSync2(candidate, bytes, { flag: "wx" });
32446
32800
  return candidate;
32447
32801
  } catch (err) {
32448
32802
  if (err.code !== "EEXIST") throw err;
@@ -32484,7 +32838,7 @@ function deliver(bytes, contentType, filename, inline) {
32484
32838
  const dir = outputDir();
32485
32839
  let path;
32486
32840
  try {
32487
- mkdirSync(dir, { recursive: true });
32841
+ mkdirSync2(dir, { recursive: true });
32488
32842
  path = writeWithoutClobbering(dir, filename, bytes);
32489
32843
  } catch (cause) {
32490
32844
  if (cause instanceof McpToolError) throw cause;
@@ -32570,6 +32924,181 @@ function registerReportTools(server, client2) {
32570
32924
  );
32571
32925
  }
32572
32926
 
32927
+ // node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
32928
+ function statusOf(err) {
32929
+ if (typeof err !== "object" || err === null)
32930
+ return void 0;
32931
+ const s = err.status ?? err.statusCode;
32932
+ return typeof s === "number" ? s : void 0;
32933
+ }
32934
+ var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
32935
+ "ok",
32936
+ "no_credential",
32937
+ "credential_rejected",
32938
+ "timeout",
32939
+ "http",
32940
+ "transport",
32941
+ "unknown"
32942
+ ]);
32943
+ function isArm(kind) {
32944
+ return kind !== void 0 && CREDENTIAL_ARMS.has(kind);
32945
+ }
32946
+ function credentialHint(arm, prefix, hostLabel, source) {
32947
+ switch (arm) {
32948
+ case "ok":
32949
+ return `Credential from '${source}' works: ${hostLabel} accepted an authenticated request. If a real tool still fails, the problem is that tool, not auth.`;
32950
+ case "no_credential":
32951
+ 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.`;
32952
+ case "credential_rejected":
32953
+ return `${hostLabel} rejected the credential from '${source}'. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
32954
+ case "timeout":
32955
+ return `The credential from '${source}' resolved, but ${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
32956
+ case "http":
32957
+ return `${hostLabel} answered with an error status that is not an auth rejection. That is USUALLY a ${hostLabel}-side problem rather than an auth one \u2014 but a 404 here more often means the probe path is wrong than that ${hostLabel} is broken, so check error.message and probe.url before concluding anything about the credential.`;
32958
+ case "transport":
32959
+ return `Could not reach ${hostLabel} at all. Check network egress; the credential itself was never judged.`;
32960
+ default:
32961
+ return `Unexpected failure \u2014 see error.message.`;
32962
+ }
32963
+ }
32964
+ function registerCredentialHealthcheckTool(args) {
32965
+ const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
32966
+ const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
32967
+ server.registerTool(`${prefix}_healthcheck`, {
32968
+ title: "Verify credentials and upstream reachability",
32969
+ 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.`,
32970
+ annotations: {
32971
+ title: "Verify credentials and upstream reachability",
32972
+ readOnlyHint: true,
32973
+ idempotentHint: true,
32974
+ openWorldHint: true
32975
+ },
32976
+ inputSchema: {}
32977
+ }, async () => {
32978
+ let probeStarted = 0;
32979
+ let state;
32980
+ try {
32981
+ state = await resolveCredential();
32982
+ } catch (e) {
32983
+ const classified = classifyThrown?.(e);
32984
+ const result2 = {
32985
+ ok: false,
32986
+ // Still false, and still no source: a classification explains WHY
32987
+ // nothing resolved, it does not invent a credential that did.
32988
+ credential: { source: null, resolved: false },
32989
+ // No `url`: nothing was probed, and naming one implies it was tried.
32990
+ probe: { elapsed_ms: 0 },
32991
+ error: {
32992
+ kind: classified?.kind ?? "no_credential",
32993
+ message: truncateErrorMessage(messageOf(e)),
32994
+ ...classified?.detail !== void 0 ? { detail: classified.detail } : {}
32995
+ },
32996
+ // The hint must follow the KIND beside it. Falling back to
32997
+ // `no_credential`'s copy under a classified kind would state a cause
32998
+ // the kind contradicts — the same disagreement this path exists to
32999
+ // remove. So: an inline hint wins; else the classified arm's own
33000
+ // copy (consumer override first); else, for a kind this module has
33001
+ // no copy for, the neutral `unknown` text rather than one that
33002
+ // asserts a cause; else the unclassified `no_credential` default.
33003
+ 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))
33004
+ };
33005
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
33006
+ }
33007
+ const credential = {
33008
+ source: state.source,
33009
+ resolved: state.source !== null,
33010
+ ...state.detail !== void 0 ? { detail: state.detail } : {}
33011
+ };
33012
+ if (!credential.resolved) {
33013
+ const result2 = {
33014
+ ok: false,
33015
+ credential,
33016
+ probe: { elapsed_ms: 0 },
33017
+ error: { kind: "no_credential", message: "no credential source resolved" },
33018
+ hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
33019
+ };
33020
+ return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
33021
+ }
33022
+ let arm = "ok";
33023
+ let error51;
33024
+ let status;
33025
+ let customHint;
33026
+ probeStarted = Date.now();
33027
+ try {
33028
+ await probeFn();
33029
+ } catch (e) {
33030
+ status = statusOf(e);
33031
+ const aborted2 = e instanceof Error && e.name === "AbortError";
33032
+ 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";
33033
+ let kind = arm;
33034
+ let detail;
33035
+ const custom2 = classifyThrown?.(e);
33036
+ if (custom2) {
33037
+ kind = custom2.kind;
33038
+ customHint = custom2.hint;
33039
+ detail = custom2.detail;
33040
+ }
33041
+ error51 = {
33042
+ kind,
33043
+ // Redacted AND bounded before it reaches the result: an upstream
33044
+ // failure routinely quotes what it was sent, and a healthcheck is
33045
+ // the tool people paste into a chat when something is broken.
33046
+ message: truncateErrorMessage(messageOf(e)),
33047
+ ...detail !== void 0 ? { detail } : {}
33048
+ };
33049
+ }
33050
+ const result = {
33051
+ ok: error51 === void 0,
33052
+ credential,
33053
+ probe: {
33054
+ ...probeUrl ? { url: probeUrl } : {},
33055
+ elapsed_ms: Date.now() - probeStarted,
33056
+ ...status !== void 0 ? { status } : {}
33057
+ },
33058
+ ...error51 ? { error: error51 } : {},
33059
+ hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
33060
+ };
33061
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
33062
+ });
33063
+ }
33064
+
33065
+ // src/tools/health.ts
33066
+ var NOT_CONFIGURED = "credentials are not configured";
33067
+ function classifyMhlbError(err) {
33068
+ const msg = err instanceof Error ? err.message : String(err);
33069
+ if (msg.includes(NOT_CONFIGURED)) return { kind: "no_credential" };
33070
+ if (msg.includes("Could not reach My Hot Lunchbox")) {
33071
+ return {
33072
+ kind: "unreachable",
33073
+ hint: "Could not reach My Hot Lunchbox at all, so the credential was never tested. Check network connectivity, or MYHOTLUNCHBOX_BASE_URL if the app has moved."
33074
+ };
33075
+ }
33076
+ if (msg.includes("rejected the sign-in")) {
33077
+ return {
33078
+ kind: "credential_rejected",
33079
+ hint: "My Hot Lunchbox rejected the sign-in. Check MYHOTLUNCHBOX_USERNAME / MYHOTLUNCHBOX_PASSWORD against https://ordernow.myhotlunchbox.com \u2014 but do NOT re-run this with guesses: repeated failures can lock the account or force a CAPTCHA that blocks server-side sign-in entirely."
33080
+ };
33081
+ }
33082
+ return void 0;
33083
+ }
33084
+ function registerHealthcheckTools(server, client2, readConfig = () => loadConfig()) {
33085
+ registerCredentialHealthcheckTool({
33086
+ server,
33087
+ prefix: "mhlb",
33088
+ hostLabel: "ordernow.myhotlunchbox.com",
33089
+ probePath: "/api/auth/userinfo",
33090
+ resolveCredential: async () => {
33091
+ const { username, password, baseUrl } = readConfig();
33092
+ const source = username && password ? "MYHOTLUNCHBOX_USERNAME+MYHOTLUNCHBOX_PASSWORD" : null;
33093
+ return { source, detail: { base_url: baseUrl } };
33094
+ },
33095
+ // The same authenticated read `mhlb_whoami` makes: cheap, and it changes
33096
+ // nothing — no order is placed, no balance moved.
33097
+ probeFn: () => client2.get("/auth/userinfo"),
33098
+ classifyThrown: classifyMhlbError
33099
+ });
33100
+ }
33101
+
32573
33102
  // src/index.ts
32574
33103
  await loadDotenvSafely();
32575
33104
  var client = new MhlbClient();
@@ -32585,6 +33114,7 @@ await runMcp({
32585
33114
  registerOrderTools,
32586
33115
  registerBillingTools,
32587
33116
  registerCheckoutTools,
32588
- registerReportTools
33117
+ registerReportTools,
33118
+ registerHealthcheckTools
32589
33119
  ]
32590
33120
  });
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { registerOrderTools } from './tools/orders.js';
9
9
  import { registerBillingTools } from './tools/billing.js';
10
10
  import { registerCheckoutTools } from './tools/checkout.js';
11
11
  import { registerReportTools } from './tools/reports.js';
12
+ import { registerHealthcheckTools } from './tools/health.js';
12
13
  await loadDotenvSafely();
13
14
  // Built here, in the caller, so the deferred-config-error pattern holds: the
14
15
  // server still boots (and answers the host's install-time tools/list probe)
@@ -27,5 +28,6 @@ await runMcp({
27
28
  registerBillingTools,
28
29
  registerCheckoutTools,
29
30
  registerReportTools,
31
+ registerHealthcheckTools,
30
32
  ],
31
33
  });
@@ -0,0 +1,63 @@
1
+ import { createFileStatePersistence, resolveStateFile, } from '@chrischall/mcp-utils/session';
2
+ import { readEnvVar, parseBoolEnv } from '@chrischall/mcp-utils';
3
+ /** Where the OAuth token pair is cached between runs. */
4
+ export function tokenCachePath(env = process.env) {
5
+ return resolveStateFile({
6
+ env,
7
+ envVar: 'MYHOTLUNCHBOX_TOKEN_FILE',
8
+ subdir: '.myhotlunchbox-mcp',
9
+ fileName: 'token.json',
10
+ });
11
+ }
12
+ /** Only a token pair is ever stored — never the username or password. */
13
+ function isTokens(raw) {
14
+ if (raw === null || typeof raw !== 'object')
15
+ return false;
16
+ const t = raw;
17
+ return (typeof t.accessToken === 'string' &&
18
+ t.accessToken !== '' &&
19
+ typeof t.expiresAt === 'number' &&
20
+ (t.refreshToken === undefined || typeof t.refreshToken === 'string'));
21
+ }
22
+ /**
23
+ * The token cache, or `null` when it is off or there are no credentials to
24
+ * bind a record to.
25
+ *
26
+ * A restart currently spends a full password grant for a token that is usually
27
+ * still valid — and, when it is not, a refresh would have done. Caching turns
28
+ * the first case into nothing and the second into one refresh, which matters on
29
+ * a host where a child idles out after ten minutes and every start is cold.
30
+ *
31
+ * The record is bound to the credentials that minted it, so rotating either
32
+ * discards it rather than leaving a token from the old password in play. Only a
33
+ * salted digest is written; neither value reaches the file.
34
+ */
35
+ export function createTokenCache(env = process.env) {
36
+ if (!parseBoolEnv('MYHOTLUNCHBOX_TOKEN_CACHE', { env, default: true }))
37
+ return null;
38
+ const username = readEnvVar('MYHOTLUNCHBOX_USERNAME', { env });
39
+ const password = readEnvVar('MYHOTLUNCHBOX_PASSWORD', { env });
40
+ if (username === undefined || password === undefined)
41
+ return null;
42
+ return createFileStatePersistence({
43
+ filePath: tokenCachePath(env),
44
+ // Joined on a NUL, written as an escape rather than a literal byte: a
45
+ // password may contain spaces, so a space-joined pair could collide with a
46
+ // different pair by shifting the boundary between the two halves.
47
+ boundTo: [username.trim().toLowerCase(), password].join('\u0000'),
48
+ validate: (raw) => (isTokens(raw) ? raw : null),
49
+ });
50
+ }
51
+ /**
52
+ * Report a cache write that failed. Not fatal: the tokens are re-mintable from
53
+ * the credentials in the environment, so a lost write costs the next start a
54
+ * login rather than access. Worth saying, though — a read-only data dir
55
+ * otherwise looks exactly like a server that never caches.
56
+ *
57
+ * stderr only; stdout is the JSON-RPC channel.
58
+ */
59
+ export function reportCacheWriteFailure(err) {
60
+ const detail = err instanceof Error ? err.message : String(err);
61
+ console.error(`[myhotlunchbox-mcp] could not cache the OAuth tokens (${detail}); continuing ` +
62
+ 'without the cache — every restart will log in again until this is fixed.');
63
+ }
@@ -0,0 +1,65 @@
1
+ import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
2
+ import { loadConfig } from '../config.js';
3
+ /**
4
+ * `mhlb_healthcheck` — the one call that answers "is this connector
5
+ * working?", and the only tool here that reports a failure as DATA rather
6
+ * than throwing.
7
+ *
8
+ * My Hot Lunchbox had none. `mhlb_whoami` looks like one — its own
9
+ * description says "Start here to confirm the session works" — and is not: it
10
+ * is a plain data read that THROWS when auth fails, so the caller gets an
11
+ * exception, not an answer, and cannot tell a bad password from an
12
+ * unreachable host from a lockout.
13
+ *
14
+ * The lockout is why this matters more here than elsewhere. This account
15
+ * counts failed sign-ins and will lock or force a CAPTCHA that blocks
16
+ * server-side sign-in entirely, so a `credential_rejected` hint that invites
17
+ * retrying is actively harmful. {@link classifyMhlbError} carries that
18
+ * warning instead.
19
+ */
20
+ const NOT_CONFIGURED = 'credentials are not configured';
21
+ export function classifyMhlbError(err) {
22
+ const msg = err instanceof Error ? err.message : String(err);
23
+ if (msg.includes(NOT_CONFIGURED))
24
+ return { kind: 'no_credential' };
25
+ // The host is unreachable, or MYHOTLUNCHBOX_BASE_URL points somewhere wrong.
26
+ // Nothing here says the credential is bad — do not send anyone to change it.
27
+ if (msg.includes('Could not reach My Hot Lunchbox')) {
28
+ return {
29
+ kind: 'unreachable',
30
+ hint: 'Could not reach My Hot Lunchbox at all, so the credential was never tested. ' +
31
+ 'Check network connectivity, or MYHOTLUNCHBOX_BASE_URL if the app has moved.',
32
+ };
33
+ }
34
+ if (msg.includes('rejected the sign-in')) {
35
+ return {
36
+ kind: 'credential_rejected',
37
+ hint: 'My Hot Lunchbox rejected the sign-in. Check MYHOTLUNCHBOX_USERNAME / MYHOTLUNCHBOX_PASSWORD against ' +
38
+ 'https://ordernow.myhotlunchbox.com — but do NOT re-run this with guesses: repeated failures can lock ' +
39
+ 'the account or force a CAPTCHA that blocks server-side sign-in entirely.',
40
+ };
41
+ }
42
+ return undefined;
43
+ }
44
+ export function registerHealthcheckTools(server, client,
45
+ /** Seam: injectable so tests need no process env. */
46
+ readConfig = () => loadConfig()) {
47
+ registerCredentialHealthcheckTool({
48
+ server,
49
+ prefix: 'mhlb',
50
+ hostLabel: 'ordernow.myhotlunchbox.com',
51
+ probePath: '/api/auth/userinfo',
52
+ resolveCredential: async () => {
53
+ const { username, password, baseUrl } = readConfig();
54
+ // Both halves or nothing: a username with no password cannot sign in,
55
+ // and sending it to the API would spend a failed attempt against an
56
+ // account that locks.
57
+ const source = username && password ? 'MYHOTLUNCHBOX_USERNAME+MYHOTLUNCHBOX_PASSWORD' : null;
58
+ return { source, detail: { base_url: baseUrl } };
59
+ },
60
+ // The same authenticated read `mhlb_whoami` makes: cheap, and it changes
61
+ // nothing — no order is placed, no balance moved.
62
+ probeFn: () => client.get('/auth/userinfo'),
63
+ classifyThrown: classifyMhlbError,
64
+ });
65
+ }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of truth for the server version; release-please rewrites it. */
2
- export const VERSION = '0.2.1'; // x-release-please-version
2
+ export const VERSION = '0.4.0'; // x-release-please-version
package/mint.yaml CHANGED
@@ -33,7 +33,10 @@ env:
33
33
  help: >-
34
34
  Password for that account. Used for a server-side OAuth2 password grant;
35
35
  the refresh token it returns renews the session, so no browser or
36
- extension is involved and nothing is written to disk.
36
+ extension is involved. The password itself is never written to disk — the
37
+ token pair it mints is cached at
38
+ $MCP_DATA_DIR/.myhotlunchbox-mcp/token.json (0600), bound to a salted
39
+ digest of these credentials so rotating either discards it.
37
40
 
38
41
  - name: MYHOTLUNCHBOX_BASE_URL
39
42
  secret: false
@@ -52,10 +55,27 @@ env:
52
55
  the filesystem belongs to the runner, so prefer calling those tools with
53
56
  inline: true and taking the bytes back in the result.
54
57
 
58
+ - name: MYHOTLUNCHBOX_TOKEN_CACHE
59
+ secret: false
60
+ required: false
61
+ help: >-
62
+ Set to false to disable the on-disk token cache and log in on every
63
+ process start. Defaults to enabled.
64
+ - name: MYHOTLUNCHBOX_TOKEN_FILE
65
+ secret: false
66
+ required: false
67
+ help: >-
68
+ Absolute path for the token cache file. Defaults to
69
+ $MCP_DATA_DIR/.myhotlunchbox-mcp/token.json.
55
70
  state:
56
- # Sessions live in memory for the life of the child and are re-established by
57
- # signing in again, so a cold start costs one login and loses nothing.
58
- dataDir: false
71
+ dataDir: true
72
+ reason: >-
73
+ Caches the OAuth token pair at $MCP_DATA_DIR/.myhotlunchbox-mcp/token.json
74
+ (0600). This was dataDir:false while sessions lived only in memory, on the
75
+ reasoning that a cold start cost one login and lost nothing — true then, and
76
+ no longer: the cache is what turns those starts into nothing, and without a
77
+ persistent dir it is written to a rootfs the next idle-stop discards, so it
78
+ would never be read back.
59
79
 
60
80
  identity:
61
81
  # Credentials are per-registration secrets, not per-caller, so one shared
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myhotlunchbox-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.chrischall/myhotlunchbox-mcp",
5
5
  "description": "My Hot Lunchbox MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38,14 +38,14 @@
38
38
  "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
39
39
  "dev": "node dist/index.js",
40
40
  "typecheck": "tsc --noEmit",
41
- "test": "vitest run",
41
+ "test": "npm run typecheck && vitest run",
42
42
  "test:watch": "vitest",
43
- "test:coverage": "vitest run --coverage",
43
+ "test:coverage": "npm run typecheck && vitest run --coverage",
44
44
  "verify:reads": "node scripts/verify-reads.mjs",
45
45
  "capture:writes": "node scripts/capture-writes.mjs"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^0.15.0",
48
+ "@chrischall/mcp-utils": "^0.19.3",
49
49
  "@modelcontextprotocol/sdk": "^1.29.0",
50
50
  "dotenv": "^17.4.0",
51
51
  "zod": "^4.4.2"
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/myhotlunchbox-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.2.1",
9
+ "version": "0.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "myhotlunchbox-mcp",
14
- "version": "0.2.1",
14
+ "version": "0.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -43,6 +43,20 @@
43
43
  "isRequired": false,
44
44
  "format": "string",
45
45
  "isSecret": false
46
+ },
47
+ {
48
+ "name": "MYHOTLUNCHBOX_TOKEN_CACHE",
49
+ "description": "Set to false to disable the on-disk token cache and re-authenticate on every process start. Defaults to enabled.",
50
+ "isRequired": false,
51
+ "format": "string",
52
+ "isSecret": false
53
+ },
54
+ {
55
+ "name": "MYHOTLUNCHBOX_TOKEN_FILE",
56
+ "description": "Absolute path for the token cache file. Defaults to $MCP_DATA_DIR/.myhotlunchbox-mcp/token.json.",
57
+ "isRequired": false,
58
+ "format": "string",
59
+ "isSecret": false
46
60
  }
47
61
  ]
48
62
  }