myhotlunchbox-mcp 0.3.0 → 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.3.0"
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.3.0",
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.3.0",
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/bundle.js CHANGED
@@ -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) {
@@ -32240,7 +32245,7 @@ var MhlbClient = class {
32240
32245
  };
32241
32246
 
32242
32247
  // src/version.ts
32243
- var VERSION = "0.3.0";
32248
+ var VERSION = "0.4.0";
32244
32249
 
32245
32250
  // src/tools/_shared.ts
32246
32251
  function preview(action, request, notes) {
@@ -32919,6 +32924,181 @@ function registerReportTools(server, client2) {
32919
32924
  );
32920
32925
  }
32921
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
+
32922
33102
  // src/index.ts
32923
33103
  await loadDotenvSafely();
32924
33104
  var client = new MhlbClient();
@@ -32934,6 +33114,7 @@ await runMcp({
32934
33114
  registerOrderTools,
32935
33115
  registerBillingTools,
32936
33116
  registerCheckoutTools,
32937
- registerReportTools
33117
+ registerReportTools,
33118
+ registerHealthcheckTools
32938
33119
  ]
32939
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,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.3.0'; // x-release-please-version
2
+ export const VERSION = '0.4.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myhotlunchbox-mcp",
3
- "version": "0.3.0",
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>",
@@ -45,7 +45,7 @@
45
45
  "capture:writes": "node scripts/capture-writes.mjs"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^0.17.1",
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.3.0",
9
+ "version": "0.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "myhotlunchbox-mcp",
14
- "version": "0.3.0",
14
+ "version": "0.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },