myhotlunchbox-mcp 0.2.1 → 0.3.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.3.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.3.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.3.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/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
  }
@@ -31440,6 +31440,20 @@ function readEnvVar(key, opts = {}) {
31440
31440
  }
31441
31441
  return opts.default;
31442
31442
  }
31443
+ var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
31444
+ var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
31445
+ function parseBoolEnv(key, opts = {}) {
31446
+ const fallback = opts.default ?? false;
31447
+ const raw = readEnvVar(key, { env: opts.env });
31448
+ if (raw === void 0)
31449
+ return fallback;
31450
+ const token = raw.toLowerCase();
31451
+ if (TRUE_TOKENS.has(token))
31452
+ return true;
31453
+ if (FALSE_TOKENS.has(token))
31454
+ return false;
31455
+ return fallback;
31456
+ }
31443
31457
  async function loadDotenvSafely(opts = {}) {
31444
31458
  try {
31445
31459
  const mod = await import(
@@ -31457,6 +31471,34 @@ async function loadDotenvSafely(opts = {}) {
31457
31471
  }
31458
31472
  }
31459
31473
 
31474
+ // node_modules/@chrischall/mcp-utils/dist/http/index.js
31475
+ var RateLimitedError = class extends Error {
31476
+ status = 429;
31477
+ constructor(service) {
31478
+ super(`Rate limited (429) by ${service} after retries.`);
31479
+ this.name = "RateLimitedError";
31480
+ Object.setPrototypeOf(this, new.target.prototype);
31481
+ }
31482
+ };
31483
+ var RequestTimeoutError = class extends Error {
31484
+ timeoutMs;
31485
+ constructor(service, timeoutMs) {
31486
+ super(`Request to ${service} timed out after ${timeoutMs}ms.`);
31487
+ this.name = "RequestTimeoutError";
31488
+ this.timeoutMs = timeoutMs;
31489
+ Object.setPrototypeOf(this, new.target.prototype);
31490
+ }
31491
+ };
31492
+ var ApiError = class extends Error {
31493
+ status;
31494
+ constructor(status, message) {
31495
+ super(message);
31496
+ this.name = "ApiError";
31497
+ this.status = status;
31498
+ Object.setPrototypeOf(this, new.target.prototype);
31499
+ }
31500
+ };
31501
+
31460
31502
  // node_modules/@chrischall/mcp-utils/dist/zod/index.js
31461
31503
  var PositiveInt = external_exports.number().int().positive();
31462
31504
  var NonNegInt = external_exports.number().int().nonnegative();
@@ -31485,57 +31527,313 @@ function toolAnnotations(opts = {}) {
31485
31527
  }
31486
31528
 
31487
31529
  // node_modules/@chrischall/mcp-utils/dist/session/index.js
31530
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
31531
+ import { dirname, join, resolve } from "node:path";
31532
+ import { homedir } from "node:os";
31533
+ import { randomBytes, createHmac } from "node:crypto";
31534
+ function bindingDigest(secret, salt) {
31535
+ return createHmac("sha256", salt).update(secret).digest("hex").slice(0, 32);
31536
+ }
31537
+ function isEnvelope(raw) {
31538
+ return raw !== null && typeof raw === "object" && raw.v === 1 && "state" in raw;
31539
+ }
31540
+ var StatePersistenceError = class extends Error {
31541
+ cause;
31542
+ constructor(cause) {
31543
+ super(cause instanceof Error ? cause.message : String(cause));
31544
+ this.name = "StatePersistenceError";
31545
+ this.cause = cause;
31546
+ Object.setPrototypeOf(this, new.target.prototype);
31547
+ }
31548
+ };
31549
+ function createFileStatePersistence(opts) {
31550
+ const { filePath, validate } = opts;
31551
+ const secret = opts.boundTo;
31552
+ return {
31553
+ load() {
31554
+ if (!existsSync(filePath))
31555
+ return null;
31556
+ try {
31557
+ const raw = JSON.parse(readFileSync(filePath, "utf8"));
31558
+ let state;
31559
+ if (isEnvelope(raw)) {
31560
+ const b = raw.boundTo;
31561
+ if (secret === void 0) {
31562
+ if (b !== void 0)
31563
+ return null;
31564
+ } else {
31565
+ if (b === void 0)
31566
+ return null;
31567
+ if (bindingDigest(secret, b.salt) !== b.digest)
31568
+ return null;
31569
+ }
31570
+ state = raw.state;
31571
+ } else {
31572
+ if (secret !== void 0)
31573
+ return null;
31574
+ state = raw;
31575
+ }
31576
+ if (validate !== void 0)
31577
+ return validate(state);
31578
+ return state;
31579
+ } catch {
31580
+ return null;
31581
+ }
31582
+ },
31583
+ save(state) {
31584
+ const dir = dirname(filePath);
31585
+ const tmp = `${filePath}.tmp-${randomBytes(6).toString("hex")}`;
31586
+ const dirExisted = existsSync(dir);
31587
+ try {
31588
+ mkdirSync(dir, { recursive: true, mode: 448 });
31589
+ if (!dirExisted)
31590
+ chmodSync(dir, 448);
31591
+ let boundTo;
31592
+ if (secret !== void 0) {
31593
+ const salt = randomBytes(16).toString("hex");
31594
+ boundTo = { salt, digest: bindingDigest(secret, salt) };
31595
+ }
31596
+ const envelope = { v: 1, ...boundTo !== void 0 ? { boundTo } : {}, state };
31597
+ writeFileSync(tmp, JSON.stringify(envelope, null, 2), { mode: 384 });
31598
+ chmodSync(tmp, 384);
31599
+ renameSync(tmp, filePath);
31600
+ chmodSync(filePath, 384);
31601
+ } catch (err) {
31602
+ try {
31603
+ if (existsSync(tmp))
31604
+ unlinkSync(tmp);
31605
+ } catch {
31606
+ }
31607
+ throw err;
31608
+ }
31609
+ },
31610
+ clear() {
31611
+ try {
31612
+ if (existsSync(filePath))
31613
+ unlinkSync(filePath);
31614
+ } catch {
31615
+ }
31616
+ }
31617
+ };
31618
+ }
31619
+ function homeOf(env) {
31620
+ return readEnvVar("HOME", { env }) ?? homedir();
31621
+ }
31622
+ function resolveStateDir(opts = {}) {
31623
+ const env = opts.env;
31624
+ return join(readEnvVar("MCP_DATA_DIR", { env }) ?? homeOf(env), opts.subdir ?? "");
31625
+ }
31626
+ function resolveStateFile(opts) {
31627
+ const override = opts.envVar !== void 0 ? readEnvVar(opts.envVar, { env: opts.env }) : void 0;
31628
+ if (override !== void 0) {
31629
+ if (override === "~")
31630
+ return homeOf(opts.env);
31631
+ if (override.startsWith("~/"))
31632
+ return join(homeOf(opts.env), override.slice(2));
31633
+ return resolve(override);
31634
+ }
31635
+ const dirOpts = { env: opts.env };
31636
+ if (opts.subdir !== void 0)
31637
+ dirOpts.subdir = opts.subdir;
31638
+ return join(resolveStateDir(dirOpts), opts.fileName);
31639
+ }
31488
31640
  var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
31641
+ function defaultIsRefreshRevoked(err) {
31642
+ if (err instanceof RateLimitedError || err instanceof RequestTimeoutError)
31643
+ return false;
31644
+ if (err instanceof ApiError && err.status >= 500)
31645
+ return false;
31646
+ return true;
31647
+ }
31648
+ function isBearerTokens(raw) {
31649
+ if (raw === null || typeof raw !== "object")
31650
+ return false;
31651
+ const t = raw;
31652
+ if (typeof t.accessToken !== "string" || t.accessToken === "")
31653
+ return false;
31654
+ if (typeof t.expiresAt !== "number" || !Number.isFinite(t.expiresAt))
31655
+ return false;
31656
+ return t.refreshToken === void 0 || typeof t.refreshToken === "string";
31657
+ }
31489
31658
  var TokenManager = class {
31490
- accessToken;
31491
- refreshToken;
31492
- expiresAt;
31659
+ tokens;
31660
+ bootstrapFn;
31493
31661
  refreshFn;
31494
31662
  skewMs;
31663
+ persistence;
31664
+ now;
31665
+ isRefreshRevokedFn;
31666
+ onPersistErrorFn;
31495
31667
  inFlight;
31668
+ bootstrapInFlight;
31669
+ /**
31670
+ * Persistence is consulted at most once per process. Without this the
31671
+ * revoked-token recovery below re-reads the SAME rejected record — `clear()`
31672
+ * is optional on {@link StatePersistence} and its failures are swallowed, so
31673
+ * recovery must not depend on it. After the first read the in-memory tokens
31674
+ * (or their deliberate absence) are the truth.
31675
+ */
31676
+ persistenceRead = false;
31496
31677
  constructor(opts) {
31497
- this.accessToken = opts.initial.accessToken;
31498
- this.refreshToken = opts.initial.refreshToken;
31499
- this.expiresAt = opts.initial.expiresAt;
31678
+ if (typeof opts.initial === "function") {
31679
+ this.bootstrapFn = opts.initial;
31680
+ } else {
31681
+ this.tokens = { ...opts.initial };
31682
+ }
31500
31683
  this.refreshFn = opts.refresh;
31501
31684
  this.skewMs = opts.skewMs ?? TOKEN_REFRESH_SKEW_MS;
31685
+ this.persistence = opts.persistence;
31686
+ this.now = opts.now ?? Date.now;
31687
+ this.isRefreshRevokedFn = opts.isRefreshRevoked ?? defaultIsRefreshRevoked;
31688
+ this.onPersistErrorFn = opts.onPersistError;
31502
31689
  }
31503
31690
  /** Whether the token is within the skew window of (or past) expiry. */
31504
31691
  needsRefresh() {
31505
- return Date.now() >= this.expiresAt - this.skewMs;
31692
+ if (this.tokens === void 0)
31693
+ return false;
31694
+ return this.now() >= this.tokens.expiresAt - this.skewMs;
31695
+ }
31696
+ /**
31697
+ * A stored token is worth using when it is still valid, OR when it carries a
31698
+ * refresh token — an expired-but-refreshable token still saves the login,
31699
+ * which is the expensive half.
31700
+ */
31701
+ isUsable(t) {
31702
+ return this.now() < t.expiresAt - this.skewMs || t.refreshToken !== void 0;
31703
+ }
31704
+ /** Read persisted tokens, guarding shape and usability. Never throws. */
31705
+ async loadPersisted() {
31706
+ if (this.persistence === void 0 || this.persistenceRead)
31707
+ return null;
31708
+ this.persistenceRead = true;
31709
+ try {
31710
+ const raw = await this.persistence.load();
31711
+ if (!isBearerTokens(raw) || !this.isUsable(raw))
31712
+ return null;
31713
+ return raw;
31714
+ } catch {
31715
+ return null;
31716
+ }
31717
+ }
31718
+ /**
31719
+ * Write tokens. Silent by default (a lost write costs a future login, not this
31720
+ * request); throws a {@link StatePersistenceError} when `onPersistError` does.
31721
+ */
31722
+ async persist(t) {
31723
+ if (this.persistence === void 0)
31724
+ return;
31725
+ try {
31726
+ await this.persistence.save(t);
31727
+ } catch (err) {
31728
+ if (this.onPersistErrorFn === void 0)
31729
+ return;
31730
+ try {
31731
+ this.onPersistErrorFn(err);
31732
+ } catch (hookErr) {
31733
+ throw new StatePersistenceError(hookErr);
31734
+ }
31735
+ }
31736
+ }
31737
+ /** Discard persisted tokens (a refresh they could not satisfy). Never throws. */
31738
+ async clearPersisted() {
31739
+ if (this.persistence?.clear === void 0)
31740
+ return;
31741
+ try {
31742
+ await this.persistence.clear();
31743
+ } catch {
31744
+ }
31745
+ }
31746
+ /** The current tokens, single-flighting the bootstrap if there are none. */
31747
+ ensureTokens() {
31748
+ if (this.tokens !== void 0)
31749
+ return Promise.resolve(this.tokens);
31750
+ if (this.bootstrapInFlight === void 0) {
31751
+ this.bootstrapInFlight = this.runBootstrap().finally(() => {
31752
+ this.bootstrapInFlight = void 0;
31753
+ });
31754
+ }
31755
+ return this.bootstrapInFlight;
31756
+ }
31757
+ /** One bootstrap attempt: persisted tokens if usable, else the login. */
31758
+ async runBootstrap() {
31759
+ const stored = await this.loadPersisted();
31760
+ if (stored !== null) {
31761
+ this.tokens = stored;
31762
+ return stored;
31763
+ }
31764
+ if (this.bootstrapFn === void 0) {
31765
+ throw new Error("TokenManager: no tokens and no bootstrap function to mint them.");
31766
+ }
31767
+ const fresh = await this.bootstrapFn();
31768
+ this.tokens = fresh;
31769
+ try {
31770
+ await this.persist(fresh);
31771
+ } catch (err) {
31772
+ throw err instanceof StatePersistenceError ? err.cause : err;
31773
+ }
31774
+ return fresh;
31506
31775
  }
31507
31776
  /**
31508
31777
  * Single-flight refresh. Concurrent callers share one in-flight promise; it is
31509
31778
  * cleared on settle (success or failure) so a subsequent refresh can proceed.
31510
31779
  */
31511
31780
  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(() => {
31781
+ if (this.inFlight === void 0) {
31782
+ this.inFlight = this.runRefresh().finally(() => {
31525
31783
  this.inFlight = void 0;
31526
31784
  });
31527
31785
  }
31528
31786
  return this.inFlight;
31529
31787
  }
31788
+ /** One refresh attempt against the current refresh token. */
31789
+ async runRefresh() {
31790
+ const current = this.tokens ?? await this.ensureTokens();
31791
+ const rt = current.refreshToken;
31792
+ if (rt === void 0) {
31793
+ throw new Error("TokenManager: cannot refresh \u2014 no refresh token is available.");
31794
+ }
31795
+ const tok = await this.refreshFn(rt);
31796
+ this.tokens = {
31797
+ accessToken: tok.accessToken,
31798
+ // Rotation is optional: keep the current refresh token when none comes back.
31799
+ refreshToken: tok.refreshToken !== void 0 && tok.refreshToken !== "" ? tok.refreshToken : rt,
31800
+ expiresAt: tok.expiresAt
31801
+ };
31802
+ await this.persist(this.tokens);
31803
+ }
31804
+ /**
31805
+ * Recover from a refresh the current credential could not satisfy — commonly
31806
+ * a refresh token restored from a previous process and revoked since. Without
31807
+ * a bootstrap to fall back on this is terminal; with one, re-minting beats
31808
+ * staying broken forever. Shared so the two entry points cannot diverge.
31809
+ */
31810
+ async reBootstrap(err) {
31811
+ if (err instanceof StatePersistenceError)
31812
+ throw err.cause;
31813
+ if (this.bootstrapFn === void 0)
31814
+ throw err;
31815
+ if (!this.isRefreshRevokedFn(err))
31816
+ throw err;
31817
+ this.tokens = void 0;
31818
+ await this.clearPersisted();
31819
+ return this.ensureTokens();
31820
+ }
31530
31821
  /** Get a valid access token, refreshing proactively inside the skew window. */
31531
31822
  async getAccessToken() {
31532
- if (this.needsRefresh())
31533
- await this.refreshNow();
31534
- return this.accessToken;
31823
+ let tokens = this.tokens ?? await this.ensureTokens();
31824
+ if (this.needsRefresh()) {
31825
+ try {
31826
+ await this.refreshNow();
31827
+ } catch (err) {
31828
+ return (await this.reBootstrap(err)).accessToken;
31829
+ }
31830
+ tokens = this.tokens ?? tokens;
31831
+ }
31832
+ return tokens.accessToken;
31535
31833
  }
31536
- /** Current absolute expiry (epoch ms). */
31834
+ /** Current absolute expiry (epoch ms), or `0` before the first bootstrap. */
31537
31835
  getExpiresAt() {
31538
- return this.expiresAt;
31836
+ return this.tokens?.expiresAt ?? 0;
31539
31837
  }
31540
31838
  /**
31541
31839
  * Run an authenticated request with reactive 401-replay. `call` receives a
@@ -31553,9 +31851,14 @@ var TokenManager = class {
31553
31851
  const usedToken = await this.getAccessToken();
31554
31852
  let res = await call(usedToken);
31555
31853
  if (res.status === 401) {
31556
- if (this.accessToken === usedToken)
31557
- await this.refreshNow();
31558
- res = await call(this.accessToken);
31854
+ if (this.tokens?.accessToken === usedToken) {
31855
+ try {
31856
+ await this.refreshNow();
31857
+ } catch (err) {
31858
+ await this.reBootstrap(err);
31859
+ }
31860
+ }
31861
+ res = await call(this.tokens?.accessToken ?? usedToken);
31559
31862
  }
31560
31863
  return res;
31561
31864
  }
@@ -31574,6 +31877,41 @@ function loadConfig(env = process.env) {
31574
31877
  };
31575
31878
  }
31576
31879
 
31880
+ // src/token-cache.ts
31881
+ function tokenCachePath(env = process.env) {
31882
+ return resolveStateFile({
31883
+ env,
31884
+ envVar: "MYHOTLUNCHBOX_TOKEN_FILE",
31885
+ subdir: ".myhotlunchbox-mcp",
31886
+ fileName: "token.json"
31887
+ });
31888
+ }
31889
+ function isTokens(raw) {
31890
+ if (raw === null || typeof raw !== "object") return false;
31891
+ const t = raw;
31892
+ return typeof t.accessToken === "string" && t.accessToken !== "" && typeof t.expiresAt === "number" && (t.refreshToken === void 0 || typeof t.refreshToken === "string");
31893
+ }
31894
+ function createTokenCache(env = process.env) {
31895
+ if (!parseBoolEnv("MYHOTLUNCHBOX_TOKEN_CACHE", { env, default: true })) return null;
31896
+ const username = readEnvVar("MYHOTLUNCHBOX_USERNAME", { env });
31897
+ const password = readEnvVar("MYHOTLUNCHBOX_PASSWORD", { env });
31898
+ if (username === void 0 || password === void 0) return null;
31899
+ return createFileStatePersistence({
31900
+ filePath: tokenCachePath(env),
31901
+ // Joined on a NUL, written as an escape rather than a literal byte: a
31902
+ // password may contain spaces, so a space-joined pair could collide with a
31903
+ // different pair by shifting the boundary between the two halves.
31904
+ boundTo: [username.trim().toLowerCase(), password].join("\0"),
31905
+ validate: (raw) => isTokens(raw) ? raw : null
31906
+ });
31907
+ }
31908
+ function reportCacheWriteFailure(err) {
31909
+ const detail = err instanceof Error ? err.message : String(err);
31910
+ console.error(
31911
+ `[myhotlunchbox-mcp] could not cache the OAuth tokens (${detail}); continuing without the cache \u2014 every restart will log in again until this is fixed.`
31912
+ );
31913
+ }
31914
+
31577
31915
  // src/auth.ts
31578
31916
  var DEFAULT_TOKEN_LIFETIME_S = 3600;
31579
31917
  function scrubCredentials(text, secrets) {
@@ -31681,13 +32019,24 @@ var MhlbAuth = class _MhlbAuth {
31681
32019
  if (this.manager) return this.manager;
31682
32020
  if (this.loginInFlight) return this.loginInFlight;
31683
32021
  this.loginInFlight = (async () => {
31684
- const body = await this.passwordLogin();
31685
32022
  const manager = new TokenManager({
31686
- initial: {
31687
- accessToken: this.assertAccessToken(body),
31688
- refreshToken: body.refresh_token,
31689
- expiresAt: _MhlbAuth.expiryOf(body)
32023
+ // The FUNCTION form, so the cache is consulted before the password
32024
+ // grant runs at all — the eager object form skips persistence, which
32025
+ // would mean a cache that is written and never read.
32026
+ initial: async () => {
32027
+ const body = await this.passwordLogin();
32028
+ return {
32029
+ accessToken: this.assertAccessToken(body),
32030
+ refreshToken: body.refresh_token,
32031
+ expiresAt: _MhlbAuth.expiryOf(body)
32032
+ };
31690
32033
  },
32034
+ persistence: createTokenCache() ?? void 0,
32035
+ onPersistError: reportCacheWriteFailure,
32036
+ // `refresh` below already recovers from a revoked refresh token by
32037
+ // falling back to a full login, so the library's own re-mint-on-revoked
32038
+ // recovery would only add a third attempt after both have failed.
32039
+ isRefreshRevoked: () => false,
31691
32040
  refresh: async (refreshToken) => {
31692
32041
  let next;
31693
32042
  try {
@@ -31891,7 +32240,7 @@ var MhlbClient = class {
31891
32240
  };
31892
32241
 
31893
32242
  // src/version.ts
31894
- var VERSION = "0.2.1";
32243
+ var VERSION = "0.3.0";
31895
32244
 
31896
32245
  // src/tools/_shared.ts
31897
32246
  function preview(action, request, notes) {
@@ -32420,12 +32769,12 @@ function checkoutBody(args) {
32420
32769
  }
32421
32770
 
32422
32771
  // src/tools/reports.ts
32423
- import { mkdirSync, writeFileSync } from "node:fs";
32424
- import { isAbsolute, join, resolve } from "node:path";
32772
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
32773
+ import { isAbsolute, join as join2, resolve as resolve2 } from "node:path";
32425
32774
  var MAX_INLINE_BYTES = 75e4;
32426
32775
  function outputDir(env = process.env) {
32427
32776
  const configured = readEnvVar("MYHOTLUNCHBOX_OUTPUT_DIR", { env });
32428
- return configured ? resolve(configured) : process.cwd();
32777
+ return configured ? resolve2(configured) : process.cwd();
32429
32778
  }
32430
32779
  function* candidateNames(filename) {
32431
32780
  const dot = filename.lastIndexOf(".");
@@ -32440,9 +32789,9 @@ function writeWithoutClobbering(dir, filename, bytes) {
32440
32789
  for (const name of candidateNames(filename)) {
32441
32790
  if (attempts >= MAX_NAME_ATTEMPTS) break;
32442
32791
  attempts += 1;
32443
- const candidate = join(dir, name);
32792
+ const candidate = join2(dir, name);
32444
32793
  try {
32445
- writeFileSync(candidate, bytes, { flag: "wx" });
32794
+ writeFileSync2(candidate, bytes, { flag: "wx" });
32446
32795
  return candidate;
32447
32796
  } catch (err) {
32448
32797
  if (err.code !== "EEXIST") throw err;
@@ -32484,7 +32833,7 @@ function deliver(bytes, contentType, filename, inline) {
32484
32833
  const dir = outputDir();
32485
32834
  let path;
32486
32835
  try {
32487
- mkdirSync(dir, { recursive: true });
32836
+ mkdirSync2(dir, { recursive: true });
32488
32837
  path = writeWithoutClobbering(dir, filename, bytes);
32489
32838
  } catch (cause) {
32490
32839
  if (cause instanceof McpToolError) throw cause;
@@ -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
+ }
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.3.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.3.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.17.1",
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.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "myhotlunchbox-mcp",
14
- "version": "0.2.1",
14
+ "version": "0.3.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
  }