myhotlunchbox-mcp 0.1.0 → 0.2.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.1.0"
10
+ "version": "0.2.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.1.0",
18
+ "version": "0.2.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.1.0",
4
+ "version": "0.2.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/bundle.js CHANGED
@@ -31736,6 +31736,10 @@ var MhlbAuth = class _MhlbAuth {
31736
31736
  };
31737
31737
 
31738
31738
  // src/client.ts
31739
+ var PDF_MAGIC = [37, 80, 68, 70];
31740
+ function isPdf(bytes) {
31741
+ return bytes.length >= PDF_MAGIC.length && PDF_MAGIC.every((b, i) => bytes[i] === b);
31742
+ }
31739
31743
  function buildQuery(query) {
31740
31744
  if (!query) return "";
31741
31745
  const params = new URLSearchParams();
@@ -31867,10 +31871,12 @@ var MhlbClient = class {
31867
31871
  }
31868
31872
  const contentType = res.headers.get("content-type") ?? "application/octet-stream";
31869
31873
  const bytes = new Uint8Array(await res.arrayBuffer());
31870
- if (/text\/html|application\/json/i.test(contentType)) {
31874
+ if (!isPdf(bytes)) {
31871
31875
  throw new McpToolError(
31872
- `My Hot Lunchbox returned ${contentType} for POST ${path}, not a document.`,
31873
- { hint: "The session may have lapsed. Run mhlb_session_reset and retry." }
31876
+ `My Hot Lunchbox returned ${contentType} for POST ${path}, not a PDF.`,
31877
+ {
31878
+ hint: "The session may have lapsed into a sign-in page. Run mhlb_session_reset and retry; if it persists, check the request arguments."
31879
+ }
31874
31880
  );
31875
31881
  }
31876
31882
  return { bytes, contentType };
@@ -31885,7 +31891,7 @@ var MhlbClient = class {
31885
31891
  };
31886
31892
 
31887
31893
  // src/version.ts
31888
- var VERSION = "0.1.0";
31894
+ var VERSION = "0.2.0";
31889
31895
 
31890
31896
  // src/tools/_shared.ts
31891
31897
  function preview(action, request, notes) {
@@ -32378,20 +32384,38 @@ function findTotal(payload) {
32378
32384
  }
32379
32385
 
32380
32386
  // src/tools/reports.ts
32381
- import { mkdirSync, writeFileSync, existsSync } from "node:fs";
32387
+ import { mkdirSync, writeFileSync } from "node:fs";
32382
32388
  import { isAbsolute, join, resolve } from "node:path";
32383
32389
  var MAX_INLINE_BYTES = 75e4;
32384
32390
  function outputDir(env = process.env) {
32385
32391
  const configured = readEnvVar("MYHOTLUNCHBOX_OUTPUT_DIR", { env });
32386
32392
  return configured ? resolve(configured) : process.cwd();
32387
32393
  }
32388
- function nonClobberingPath(dir, filename) {
32394
+ function* candidateNames(filename) {
32389
32395
  const dot = filename.lastIndexOf(".");
32390
32396
  const stem = dot > 0 ? filename.slice(0, dot) : filename;
32391
32397
  const ext = dot > 0 ? filename.slice(dot) : "";
32392
- let candidate = join(dir, filename);
32393
- for (let n = 2; existsSync(candidate); n += 1) candidate = join(dir, `${stem} (${n})${ext}`);
32394
- return candidate;
32398
+ yield filename;
32399
+ for (let n = 2; ; n += 1) yield `${stem} (${n})${ext}`;
32400
+ }
32401
+ var MAX_NAME_ATTEMPTS = 1e3;
32402
+ function writeWithoutClobbering(dir, filename, bytes) {
32403
+ let attempts = 0;
32404
+ for (const name of candidateNames(filename)) {
32405
+ if (attempts >= MAX_NAME_ATTEMPTS) break;
32406
+ attempts += 1;
32407
+ const candidate = join(dir, name);
32408
+ try {
32409
+ writeFileSync(candidate, bytes, { flag: "wx" });
32410
+ return candidate;
32411
+ } catch (err) {
32412
+ if (err.code !== "EEXIST") throw err;
32413
+ }
32414
+ }
32415
+ throw new McpToolError(
32416
+ `Could not find a free filename for "${filename}" in ${dir} after ${MAX_NAME_ATTEMPTS} attempts.`,
32417
+ { hint: "Clear out old reports, pass an explicit filename, or set MYHOTLUNCHBOX_OUTPUT_DIR elsewhere." }
32418
+ );
32395
32419
  }
32396
32420
  function safeName(name) {
32397
32421
  if (name.trim() === "") {
@@ -32425,9 +32449,9 @@ function deliver(bytes, contentType, filename, inline) {
32425
32449
  let path;
32426
32450
  try {
32427
32451
  mkdirSync(dir, { recursive: true });
32428
- path = nonClobberingPath(dir, filename);
32429
- writeFileSync(path, bytes);
32452
+ path = writeWithoutClobbering(dir, filename, bytes);
32430
32453
  } catch (cause) {
32454
+ if (cause instanceof McpToolError) throw cause;
32431
32455
  throw new McpToolError(`Could not write the report into ${dir}.`, {
32432
32456
  hint: "Set MYHOTLUNCHBOX_OUTPUT_DIR to a writable directory, or call again with inline: true.",
32433
32457
  cause
package/dist/client.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import { McpToolError, RateLimitError, UnreachableError, truncateErrorMessage } from '@chrischall/mcp-utils';
2
2
  import { MhlbAuth, scrubCredentials } from './auth.js';
3
3
  import { API_PREFIX, loadConfig } from './config.js';
4
+ /** `%PDF` — the four magic bytes every PDF starts with. */
5
+ const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46];
6
+ /** Whether a body really is a PDF, regardless of what the header claimed. */
7
+ export function isPdf(bytes) {
8
+ return bytes.length >= PDF_MAGIC.length && PDF_MAGIC.every((b, i) => bytes[i] === b);
9
+ }
4
10
  /**
5
11
  * Build a query string, dropping unset entries so an absent optional never
6
12
  * serializes as the literal `"undefined"`.
@@ -132,10 +138,15 @@ export class MhlbClient {
132
138
  }
133
139
  const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
134
140
  const bytes = new Uint8Array(await res.arrayBuffer());
135
- // A 200 carrying HTML is the session having lapsed into a sign-in page.
136
- // Writing that to disk as a .pdf would look like success.
137
- if (/text\/html|application\/json/i.test(contentType)) {
138
- throw new McpToolError(`My Hot Lunchbox returned ${contentType} for POST ${path}, not a document.`, { hint: 'The session may have lapsed. Run mhlb_session_reset and retry.' });
141
+ // Check the bytes, not the header. A deny-list of content types misses
142
+ // `text/plain` and a response with no content-type at all, either of which
143
+ // would be written to disk as a `.pdf` and look like success. The magic
144
+ // bytes are already in hand, and they are total.
145
+ if (!isPdf(bytes)) {
146
+ throw new McpToolError(`My Hot Lunchbox returned ${contentType} for POST ${path}, not a PDF.`, {
147
+ hint: 'The session may have lapsed into a sign-in page. Run mhlb_session_reset and retry; ' +
148
+ 'if it persists, check the request arguments.',
149
+ });
139
150
  }
140
151
  return { bytes, contentType };
141
152
  }
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { isAbsolute, join, resolve } from 'node:path';
3
3
  import { McpToolError, readEnvVar, toolAnnotations, PositiveInt, IsoDate } from '@chrischall/mcp-utils';
4
4
  import { z } from 'zod';
@@ -16,15 +16,43 @@ export function outputDir(env = process.env) {
16
16
  const configured = readEnvVar('MYHOTLUNCHBOX_OUTPUT_DIR', { env });
17
17
  return configured ? resolve(configured) : process.cwd();
18
18
  }
19
- /** Append ` (2)`, ` (3)`, … rather than clobbering an existing report. */
20
- export function nonClobberingPath(dir, filename) {
19
+ /** Candidate names in order: `r.pdf`, `r (2).pdf`, `r (3).pdf`, … */
20
+ export function* candidateNames(filename) {
21
21
  const dot = filename.lastIndexOf('.');
22
22
  const stem = dot > 0 ? filename.slice(0, dot) : filename;
23
23
  const ext = dot > 0 ? filename.slice(dot) : '';
24
- let candidate = join(dir, filename);
25
- for (let n = 2; existsSync(candidate); n += 1)
26
- candidate = join(dir, `${stem} (${n})${ext}`);
27
- return candidate;
24
+ yield filename;
25
+ for (let n = 2;; n += 1)
26
+ yield `${stem} (${n})${ext}`;
27
+ }
28
+ /** How many suffixed names to try before giving up. Exported so tests track
29
+ * the real limit rather than hardcoding a copy of it. */
30
+ export const MAX_NAME_ATTEMPTS = 1000;
31
+ /**
32
+ * Write the report without ever overwriting an existing file.
33
+ *
34
+ * `existsSync` followed by a default-flag `writeFileSync` is a TOCTOU race:
35
+ * two concurrent print calls landing on the same default filename both see it
36
+ * free and the second silently clobbers the first. `wx` makes the check and
37
+ * the create one atomic operation, so the loser retries the next name instead.
38
+ */
39
+ export function writeWithoutClobbering(dir, filename, bytes) {
40
+ let attempts = 0;
41
+ for (const name of candidateNames(filename)) {
42
+ if (attempts >= MAX_NAME_ATTEMPTS)
43
+ break;
44
+ attempts += 1;
45
+ const candidate = join(dir, name);
46
+ try {
47
+ writeFileSync(candidate, bytes, { flag: 'wx' });
48
+ return candidate;
49
+ }
50
+ catch (err) {
51
+ if (err.code !== 'EEXIST')
52
+ throw err;
53
+ }
54
+ }
55
+ throw new McpToolError(`Could not find a free filename for "${filename}" in ${dir} after ${MAX_NAME_ATTEMPTS} attempts.`, { hint: 'Clear out old reports, pass an explicit filename, or set MYHOTLUNCHBOX_OUTPUT_DIR elsewhere.' });
28
56
  }
29
57
  /** Reject a filename that would escape the output directory. */
30
58
  function safeName(name) {
@@ -56,10 +84,13 @@ function deliver(bytes, contentType, filename, inline) {
56
84
  let path;
57
85
  try {
58
86
  mkdirSync(dir, { recursive: true });
59
- path = nonClobberingPath(dir, filename);
60
- writeFileSync(path, bytes);
87
+ path = writeWithoutClobbering(dir, filename, bytes);
61
88
  }
62
89
  catch (cause) {
90
+ // Let an already-actionable error through rather than burying it under a
91
+ // generic permissions message.
92
+ if (cause instanceof McpToolError)
93
+ throw cause;
63
94
  throw new McpToolError(`Could not write the report into ${dir}.`, {
64
95
  hint: 'Set MYHOTLUNCHBOX_OUTPUT_DIR to a writable directory, or call again with inline: true.',
65
96
  cause,
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.1.0'; // x-release-please-version
2
+ export const VERSION = '0.2.0'; // x-release-please-version
package/mint.yaml ADDED
@@ -0,0 +1,68 @@
1
+ # How this MCP wants to be hosted on mcp-host (see mcp-host/docs/HOSTING-MANIFEST.md).
2
+ #
3
+ # The FILENAME is load-bearing: mcp-host matches it exactly, against
4
+ # HOSTING_MANIFEST_FILE in packages/core/src/hosting-manifest-schema.ts. A name
5
+ # the reader does not expect is not an error — it is simply never found, and the
6
+ # registration preview falls back to inference with nothing to notice. If this
7
+ # file ever looks ignored, check that constant first.
8
+ #
9
+ # This file is a PROPOSAL, never an application. It is read at the resolved pin
10
+ # by the registration preview and rendered for a human; nothing here changes a
11
+ # registration until an owner saves it through the ordinary write path. It is
12
+ # NOT covered by the tarball integrity check, so it carries no secrets and
13
+ # nothing that decides what runs.
14
+ version: 1
15
+
16
+ name: My Hot Lunchbox
17
+ slug: myhotlunchbox
18
+ summary: School lunch calendar, ordering and payments for a My Hot Lunchbox parent account.
19
+
20
+ env:
21
+ - name: MYHOTLUNCHBOX_USERNAME
22
+ secret: false
23
+ required: true
24
+ help: The email address you sign in to ordernow.myhotlunchbox.com with.
25
+
26
+ - name: MYHOTLUNCHBOX_PASSWORD
27
+ secret: true
28
+ required: true
29
+ # No default, deliberately: this is the credential itself.
30
+ help: >-
31
+ Password for that account. Used for a server-side OAuth2 password grant;
32
+ the refresh token it returns renews the session, so no browser or
33
+ extension is involved and nothing is written to disk.
34
+
35
+ - name: MYHOTLUNCHBOX_BASE_URL
36
+ secret: false
37
+ required: false
38
+ default: https://ordernow.myhotlunchbox.com
39
+ help: Origin of the ordering app. Override only if it moves.
40
+
41
+ - name: MYHOTLUNCHBOX_OUTPUT_DIR
42
+ secret: false
43
+ required: false
44
+ # No default: on a hosted child the working directory is the runner's, not
45
+ # the caller's, so a written file is somewhere they cannot reach. Left unset
46
+ # so the path in the result is at least honest about being server-side.
47
+ help: >-
48
+ Directory the three print tools write PDFs into. On a hosted registration
49
+ the filesystem belongs to the runner, so prefer calling those tools with
50
+ inline: true and taking the bytes back in the result.
51
+
52
+ state:
53
+ # Sessions live in memory for the life of the child and are re-established by
54
+ # signing in again, so a cold start costs one login and loses nothing.
55
+ dataDir: false
56
+
57
+ identity:
58
+ # Credentials are per-registration secrets, not per-caller, so one shared
59
+ # child is correct. Setting this true would cost a uid and a state dir per
60
+ # caller and buy nothing.
61
+ perUserChild: false
62
+
63
+ egress:
64
+ # Every request goes to the ordering app; there is no other upstream, no
65
+ # analytics and no browser bridge. Honoured on the isolated tier, a proposal
66
+ # elsewhere.
67
+ allow:
68
+ - ordernow.myhotlunchbox.com
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myhotlunchbox-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.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>",
@@ -30,7 +30,8 @@
30
30
  ".claude-plugin",
31
31
  "skills/",
32
32
  ".mcp.json",
33
- "server.json"
33
+ "server.json",
34
+ "mint.yaml"
34
35
  ],
35
36
  "scripts": {
36
37
  "build": "tsc && npm run bundle",
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.1.0",
9
+ "version": "0.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "myhotlunchbox-mcp",
14
- "version": "0.1.0",
14
+ "version": "0.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -101,7 +101,9 @@ mhlb_pdf /parentReports/printOrders \
101
101
  '{"orderStatus":1,"eventDate":"2026-09-14","studentIds":[111627]}' \
102
102
  'Orders Details.pdf'
103
103
 
104
- # One transaction receipt — send the whole record from transactionsList
104
+ # One transaction receipt — send the record from transactionDetails.
105
+ # (A transactionsList ROW is a different shape and renders a thinner PDF;
106
+ # the list is only used here to get the id.)
105
107
  ID=$(mhlb_get /event/transactionsList | jq -r '.transactions[0].id')
106
108
  mhlb_get "/event/transactionDetails?id=$ID" | jq -c '. + {isCreditType:false}' > tx.json
107
109
  mhlb_pdf /parentReports/printTransactions "$(cat tx.json)" 'Transaction.pdf'
@@ -113,12 +115,8 @@ date and status. Treat a 500 from either as a bad request, not an outage.
113
115
 
114
116
  `printTransactions` wants the record from `/event/transactionDetails`, **not** a
115
117
  row from `/event/transactionsList` — both render, but they are different shapes
116
- and different documents. Get the id from the list, then fetch the record:
117
-
118
- ```sh
119
- ID=$(mhlb_get /event/transactionsList | jq -r '.transactions[0].id')
120
- mhlb_get "/event/transactionDetails?id=$ID" | jq -c '. + {isCreditType:false}' > tx.json
121
- ```
118
+ and different documents. The list is only used to get the id, as the recipe
119
+ above does.
122
120
 
123
121
  ## Writes — all UNVERIFIED
124
122