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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +35 -11
- package/dist/client.js +15 -4
- package/dist/tools/reports.js +40 -9
- package/dist/version.js +1 -1
- package/mint.yaml +68 -0
- package/package.json +3 -2
- package/server.json +2 -2
- package/skills/myhotlunchbox/references/endpoints.md +5 -7
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 (
|
|
31874
|
+
if (!isPdf(bytes)) {
|
|
31871
31875
|
throw new McpToolError(
|
|
31872
|
-
`My Hot Lunchbox returned ${contentType} for POST ${path}, not a
|
|
31873
|
-
{
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
32393
|
-
for (let n = 2;
|
|
32394
|
-
|
|
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 =
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
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
|
}
|
package/dist/tools/reports.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync
|
|
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
|
-
/**
|
|
20
|
-
export function
|
|
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
|
-
|
|
25
|
-
for (let n = 2
|
|
26
|
-
|
|
27
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "myhotlunchbox-mcp",
|
|
14
|
-
"version": "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
|
|
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.
|
|
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
|
|