myhotlunchbox-mcp 0.0.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.
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
3
+ "name": "myhotlunchbox-mcp",
4
+ "owner": {
5
+ "name": "Chris Hall",
6
+ "email": "chris.c.hall@gmail.com"
7
+ },
8
+ "metadata": {
9
+ "description": "MCP server for My Hot Lunchbox — order school lunches, manage students, and track payments via natural language",
10
+ "version": "0.0.0"
11
+ },
12
+ "plugins": [
13
+ {
14
+ "name": "myhotlunchbox-mcp",
15
+ "displayName": "My Hot Lunchbox",
16
+ "source": "./",
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.0.0",
19
+ "author": {
20
+ "name": "Chris Hall"
21
+ },
22
+ "homepage": "https://github.com/chrischall/myhotlunchbox-mcp",
23
+ "repository": "https://github.com/chrischall/myhotlunchbox-mcp",
24
+ "license": "MIT",
25
+ "keywords": [
26
+ "myhotlunchbox",
27
+ "school-lunch",
28
+ "lunch-ordering",
29
+ "mcp",
30
+ "parent"
31
+ ],
32
+ "category": "productivity"
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "myhotlunchbox-mcp",
3
+ "displayName": "My Hot Lunchbox",
4
+ "version": "0.0.0",
5
+ "description": "MCP server for My Hot Lunchbox — order school lunches, manage students, and track payments via natural language",
6
+ "author": {
7
+ "name": "Chris Hall",
8
+ "email": "chris.c.hall@gmail.com"
9
+ },
10
+ "homepage": "https://github.com/chrischall/myhotlunchbox-mcp",
11
+ "repository": "https://github.com/chrischall/myhotlunchbox-mcp",
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "myhotlunchbox",
15
+ "school-lunch",
16
+ "lunch-ordering",
17
+ "mcp",
18
+ "parent"
19
+ ],
20
+ "skills": "./skills/",
21
+ "mcp": "./.mcp.json"
22
+ }
package/.mcp.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "mcpServers": {
3
+ "myhotlunchbox": {
4
+ "command": "node",
5
+ "args": [
6
+ "${CLAUDE_PLUGIN_ROOT}/dist/index.js"
7
+ ],
8
+ "env": {
9
+ "MYHOTLUNCHBOX_USERNAME": "${MYHOTLUNCHBOX_USERNAME}",
10
+ "MYHOTLUNCHBOX_PASSWORD": "${MYHOTLUNCHBOX_PASSWORD}"
11
+ }
12
+ }
13
+ }
14
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Hall
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # myhotlunchbox-mcp
2
+
3
+ MCP server for [My Hot Lunchbox](https://www.myhotlunchbox.com) — read the
4
+ school lunch calendar, manage students, place and change orders, and track
5
+ deliveries and payments on a parent account.
6
+
7
+ > Developed and maintained by AI (Claude Code). Use at your own discretion.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npx myhotlunchbox-mcp
13
+ ```
14
+
15
+ Or as a Claude Code plugin:
16
+
17
+ ```sh
18
+ /plugin marketplace add chrischall/myhotlunchbox-mcp
19
+ /plugin install myhotlunchbox-mcp
20
+ ```
21
+
22
+ ## Configure
23
+
24
+ ```sh
25
+ MYHOTLUNCHBOX_USERNAME=you@example.com
26
+ MYHOTLUNCHBOX_PASSWORD=…
27
+ ```
28
+
29
+ That is the whole setup. The server performs a real server-side sign-in against
30
+ `ordernow.myhotlunchbox.com` (OAuth2 password grant) and renews the session with
31
+ the refresh token it receives — **no browser extension, no signed-in tab, no
32
+ captured cookie**. Nothing is written to disk.
33
+
34
+ `MYHOTLUNCHBOX_BASE_URL` overrides the app origin if it ever moves.
35
+
36
+ The server boots without credentials so a host's install-time `tools/list` probe
37
+ still works; the configuration error surfaces on the first tool call.
38
+
39
+ ## Tools
40
+
41
+ 38 tools, all prefixed `mhlb_`.
42
+
43
+ **Account** — `mhlb_whoami`, `mhlb_session_reset`
44
+
45
+ **Students** — `mhlb_list_students`, `mhlb_get_student_form`,
46
+ `mhlb_new_student_form`, `mhlb_create_student`, `mhlb_update_student`,
47
+ `mhlb_delete_student`
48
+
49
+ **Calendar & deliveries** — `mhlb_get_calendar`, `mhlb_get_day`,
50
+ `mhlb_next_delivery`, `mhlb_list_upcoming_deliveries`,
51
+ `mhlb_list_past_deliveries`, `mhlb_list_vendors`
52
+
53
+ **Ordering** — `mhlb_get_cart`, `mhlb_get_cart_tabs`, `mhlb_get_menu`,
54
+ `mhlb_get_order_form`, `mhlb_get_order`, `mhlb_create_order`,
55
+ `mhlb_update_order`, `mhlb_delete_order`
56
+
57
+ **Billing** — `mhlb_list_transactions`, `mhlb_get_transaction`,
58
+ `mhlb_list_subscriptions`, `mhlb_get_subscription_settings`,
59
+ `mhlb_set_subscription_enabled`, `mhlb_unsubscribe_order`,
60
+ `mhlb_list_gift_cards`, `mhlb_apply_gift_card`, `mhlb_get_coupon`,
61
+ `mhlb_apply_coupon`, `mhlb_remove_coupon`
62
+
63
+ **Checkout** — `mhlb_init_checkout`, `mhlb_checkout`
64
+
65
+ **Reports** — `mhlb_print_orders`, `mhlb_print_calendar`,
66
+ `mhlb_print_transactions`
67
+
68
+ ## Writes are confirm-gated
69
+
70
+ Every mutating tool takes `confirm`. Without `confirm: true` it makes **no**
71
+ network call and returns a dry-run preview of exactly what it would send.
72
+
73
+ `mhlb_checkout` charges a real payment method, so it takes one extra safeguard:
74
+ an `expectedTotal` that must match the total in the payment payload. Price the
75
+ cart with `mhlb_init_checkout`, read the total it reports, and pass that figure.
76
+ A stale cart fails closed instead of paying a different amount.
77
+
78
+ ### Unverified writes
79
+
80
+ The write endpoints' paths, verbs and query parameters were extracted from the
81
+ site's own compiled API client and are reliable. Their **request bodies have not
82
+ been exercised against a live account** — every such tool says so in its
83
+ description. Inspect the dry-run preview before confirming, and re-read the
84
+ resource afterwards: a `200` is not proof a write persisted.
85
+
86
+ ## Ordering is read-modify-write
87
+
88
+ There is no "add item X" call. Fetch the model, edit it, send it back whole:
89
+
90
+ 1. `mhlb_get_menu` — what is orderable for a student on a date
91
+ 2. `mhlb_get_order_form` — the order model to fill in
92
+ 3. `mhlb_create_order` — send it back (with `confirm: true`)
93
+ 4. `mhlb_init_checkout` → `mhlb_checkout` — price, then pay
94
+
95
+ Fields omitted from the payload are **cleared**, not preserved.
96
+
97
+ ## Shell skill
98
+
99
+ `skills/myhotlunchbox` covers the same account from a shell with `curl` — no MCP
100
+ process needed. Useful in scripts, or on a machine where this server is not
101
+ installed.
102
+
103
+ ## Notes
104
+
105
+ - Only the parent role is wired. The same API also serves school-admin and
106
+ vendor roles; those endpoints return `403`, which the client reports as a role
107
+ mismatch rather than a broken session.
108
+ - `docs/MYHOTLUNCHBOX-API.md` records how the API was mapped and exactly what is
109
+ verified. `docs/api-surface.txt` is the full 359-endpoint extraction.
110
+
111
+ ## Licence
112
+
113
+ MIT
package/dist/auth.js ADDED
@@ -0,0 +1,192 @@
1
+ import { McpToolError, truncateErrorMessage } from '@chrischall/mcp-utils';
2
+ import { TokenManager } from '@chrischall/mcp-utils/session';
3
+ import { API_PREFIX, OAUTH_SCOPE } from './config.js';
4
+ /** Fallback lifetime when the server omits `expires_in`. */
5
+ const DEFAULT_TOKEN_LIFETIME_S = 3600;
6
+ /**
7
+ * Strip the caller's own secrets out of an upstream body before it is ever
8
+ * rendered to a user.
9
+ *
10
+ * `truncateErrorMessage`/`redactSecrets` match secret *shapes* (Bearer, JWT,
11
+ * `sk-`, …). A password has no shape, so it would survive them untouched — and
12
+ * this is the one error path that has just sent one. Splice out the literal
13
+ * values as well. The empty-needle guard matters: `split('')` would splice the
14
+ * placeholder between every character.
15
+ */
16
+ export function scrubCredentials(text, secrets) {
17
+ let out = text;
18
+ for (const secret of secrets) {
19
+ if (!secret)
20
+ continue;
21
+ out = out.split(secret).join('[redacted]');
22
+ }
23
+ return truncateErrorMessage(out);
24
+ }
25
+ /**
26
+ * Owns the OAuth2 session against `/api/auth/login`.
27
+ *
28
+ * The web app uses a password grant with `offline_access`, so a real
29
+ * server-side login is possible — no browser bridge, no captured cookie. The
30
+ * refresh token renews the session; if the refresh grant itself fails (the
31
+ * refresh token expired or was revoked) we fall back to a full password login
32
+ * rather than dropping the caller into a re-auth loop.
33
+ */
34
+ export class MhlbAuth {
35
+ config;
36
+ fetchImpl;
37
+ manager = null;
38
+ loginInFlight = null;
39
+ constructor(config, fetchImpl = fetch) {
40
+ this.config = config;
41
+ this.fetchImpl = fetchImpl;
42
+ }
43
+ /** Credentials are checked lazily so the server still boots without them. */
44
+ requireCredentials() {
45
+ const { username, password } = this.config;
46
+ if (!username || !password) {
47
+ throw new McpToolError('My Hot Lunchbox credentials are not configured.', {
48
+ hint: 'Set MYHOTLUNCHBOX_USERNAME and MYHOTLUNCHBOX_PASSWORD (the email and password you use at ' +
49
+ 'https://ordernow.myhotlunchbox.com) in your .env or MCP host config, then retry.',
50
+ });
51
+ }
52
+ return { username, password };
53
+ }
54
+ tokenUrl() {
55
+ return `${this.config.baseUrl}${API_PREFIX}/auth/login`;
56
+ }
57
+ /**
58
+ * POST a grant to the token endpoint. The web app sends
59
+ * `application/x-www-form-urlencoded` (it builds the body with `qs.stringify`).
60
+ */
61
+ async postGrant(form) {
62
+ const { password } = this.config;
63
+ let res;
64
+ try {
65
+ res = await this.fetchImpl(this.tokenUrl(), {
66
+ method: 'POST',
67
+ headers: {
68
+ 'content-type': 'application/x-www-form-urlencoded',
69
+ accept: 'application/json',
70
+ },
71
+ body: new URLSearchParams(form).toString(),
72
+ });
73
+ }
74
+ catch (cause) {
75
+ throw new McpToolError(`Could not reach My Hot Lunchbox at ${this.config.baseUrl}.`, { hint: 'Check network connectivity, or override MYHOTLUNCHBOX_BASE_URL if the app has moved.', cause });
76
+ }
77
+ const raw = await res.text();
78
+ if (!res.ok) {
79
+ let parsed = null;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ }
83
+ catch {
84
+ /* non-JSON error body — fall through to the raw text */
85
+ }
86
+ const detail = parsed?.error_description ?? parsed?.error ?? raw;
87
+ // Never auto-retry a rejected credential: these servers count attempts.
88
+ throw new McpToolError(`My Hot Lunchbox rejected the sign-in (HTTP ${res.status}): ${scrubCredentials(detail, [password, form.password, form.refresh_token])}`, {
89
+ hint: parsed?.error === 'invalid_grant'
90
+ ? 'Check MYHOTLUNCHBOX_USERNAME / MYHOTLUNCHBOX_PASSWORD. Do not retry with guesses — repeated failures can lock the account or force a CAPTCHA that blocks server-side sign-in entirely.'
91
+ : 'Sign in once at https://ordernow.myhotlunchbox.com to confirm the account is active, then retry.',
92
+ });
93
+ }
94
+ try {
95
+ return JSON.parse(raw);
96
+ }
97
+ catch (cause) {
98
+ throw new McpToolError('My Hot Lunchbox returned a non-JSON token response.', {
99
+ hint: 'The sign-in endpoint may have changed. Re-capture the login request from the web app.',
100
+ cause,
101
+ });
102
+ }
103
+ }
104
+ static expiryOf(body) {
105
+ return Date.now() + (body.expires_in ?? DEFAULT_TOKEN_LIFETIME_S) * 1000;
106
+ }
107
+ assertAccessToken(body) {
108
+ if (!body.access_token) {
109
+ throw new McpToolError('My Hot Lunchbox returned no access token.', {
110
+ hint: 'The sign-in succeeded but carried no `access_token`. Re-capture the login request from the web app.',
111
+ });
112
+ }
113
+ return body.access_token;
114
+ }
115
+ /** Full password grant. */
116
+ async passwordLogin() {
117
+ const { username, password } = this.requireCredentials();
118
+ return this.postGrant({
119
+ grant_type: 'password',
120
+ username,
121
+ password,
122
+ scope: OAUTH_SCOPE,
123
+ });
124
+ }
125
+ /**
126
+ * Build (once) the TokenManager that fronts every authenticated call.
127
+ * Single-flight: concurrent first-callers share one login.
128
+ */
129
+ async ensureManager() {
130
+ if (this.manager)
131
+ return this.manager;
132
+ if (this.loginInFlight)
133
+ return this.loginInFlight;
134
+ this.loginInFlight = (async () => {
135
+ const body = await this.passwordLogin();
136
+ const manager = new TokenManager({
137
+ initial: {
138
+ accessToken: this.assertAccessToken(body),
139
+ refreshToken: body.refresh_token,
140
+ expiresAt: MhlbAuth.expiryOf(body),
141
+ },
142
+ refresh: async (refreshToken) => {
143
+ let next;
144
+ try {
145
+ next = await this.postGrant({ grant_type: 'refresh_token', refresh_token: refreshToken });
146
+ }
147
+ catch {
148
+ // The refresh token expired or was revoked. We still hold the
149
+ // password, so recover with a full login instead of surfacing a
150
+ // re-auth error the caller cannot act on.
151
+ next = await this.passwordLogin();
152
+ }
153
+ return {
154
+ accessToken: this.assertAccessToken(next),
155
+ refreshToken: next.refresh_token,
156
+ expiresAt: MhlbAuth.expiryOf(next),
157
+ };
158
+ },
159
+ });
160
+ this.manager = manager;
161
+ return manager;
162
+ })();
163
+ try {
164
+ return await this.loginInFlight;
165
+ }
166
+ finally {
167
+ this.loginInFlight = null;
168
+ }
169
+ }
170
+ /**
171
+ * Run an authenticated request. Delegates to {@link TokenManager.withAuth},
172
+ * which refreshes proactively inside the skew window and replays exactly once
173
+ * on a `401`.
174
+ */
175
+ async withAuth(call) {
176
+ const manager = await this.ensureManager();
177
+ return manager.withAuth(call);
178
+ }
179
+ /** Drop the cached session. Exposed for the `_signout` tool and for tests. */
180
+ reset() {
181
+ this.manager = null;
182
+ this.loginInFlight = null;
183
+ }
184
+ /** Raw fetch through the injected implementation (no auth attached). */
185
+ fetch(url, init) {
186
+ return this.fetchImpl(url, init);
187
+ }
188
+ /** Whether a session has been established in this process. */
189
+ get isAuthenticated() {
190
+ return this.manager !== null;
191
+ }
192
+ }