myhotlunchbox-mcp 1.2.4 → 1.2.6
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 +2 -2
- package/README.md +13 -6
- package/dist/auth.js +81 -5
- package/dist/bundle.js +369 -108
- package/dist/client.js +58 -8
- package/dist/token-cache.js +8 -6
- package/dist/tools/_shared.js +19 -1
- package/dist/tools/account.js +1 -1
- package/dist/tools/billing.js +16 -16
- package/dist/tools/calendar.js +5 -5
- package/dist/tools/checkout.js +2 -2
- package/dist/tools/health.js +17 -0
- package/dist/tools/orders.js +15 -15
- package/dist/tools/reports.js +40 -12
- package/dist/tools/students.js +11 -11
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/myhotlunchbox/SKILL.md +3 -1
|
@@ -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": "1.2.
|
|
10
|
+
"version": "1.2.6"
|
|
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": "1.2.
|
|
18
|
+
"version": "1.2.6",
|
|
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": "1.2.
|
|
4
|
+
"version": "1.2.6",
|
|
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",
|
|
@@ -18,5 +18,5 @@
|
|
|
18
18
|
"parent"
|
|
19
19
|
],
|
|
20
20
|
"skills": "./skills/",
|
|
21
|
-
"
|
|
21
|
+
"mcpServers": "./.mcp.json"
|
|
22
22
|
}
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ That is the whole setup. The server performs a real server-side sign-in against
|
|
|
31
31
|
the refresh token it receives — **no browser extension, no signed-in tab, no
|
|
32
32
|
captured cookie**. Nothing is written to disk.
|
|
33
33
|
|
|
34
|
-
`MYHOTLUNCHBOX_BASE_URL` overrides the app origin if it ever moves.
|
|
34
|
+
`MYHOTLUNCHBOX_BASE_URL` overrides the app origin if it ever moves. It must be `https://` (plain `http://` is accepted only for `localhost`), and the token cache is bound to it, so changing it signs in again.
|
|
35
35
|
|
|
36
36
|
The server boots without credentials so a host's install-time `tools/list` probe
|
|
37
37
|
still works; the configuration error surfaces on the first tool call.
|
|
@@ -72,8 +72,9 @@ the full code.
|
|
|
72
72
|
**Reports** — `mhlb_print_calendar`, `mhlb_print_orders`,
|
|
73
73
|
`mhlb_print_transaction`. These return real PDFs; each writes the file and
|
|
74
74
|
returns its path, or the bytes inline with `inline: true`. Set
|
|
75
|
-
`MYHOTLUNCHBOX_OUTPUT_DIR`
|
|
76
|
-
|
|
75
|
+
`MYHOTLUNCHBOX_OUTPUT_DIR` (the "Report folder" setting in the `.mcpb`) to
|
|
76
|
+
choose where they land; it defaults to `reports/` under `MCP_DATA_DIR` when the
|
|
77
|
+
host sets one, else `~/Downloads`. Existing files are never overwritten.
|
|
77
78
|
|
|
78
79
|
## Confirmations
|
|
79
80
|
|
|
@@ -87,7 +88,8 @@ changing any argument between the two calls is refused (`DRAFT_CHANGED`).
|
|
|
87
88
|
|
|
88
89
|
| variable | default | |
|
|
89
90
|
|---|---|---|
|
|
90
|
-
| `MCP_CONFIRM_MODE` | `ask-user` | What a write does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). `ask-user`: two steps — the first call does nothing and returns a preview plus a token, and the model must get your approval in chat before calling again with it. `auto`: the same two steps, but the model may use the token after reviewing the preview itself. `refuse`: writes are refused on such clients. A client that can show prompts (Claude Code) always gets the real prompt
|
|
91
|
+
| `MCP_CONFIRM_MODE` | `ask-user` | What a write does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). `ask-user`: two steps — the first call does nothing and returns a preview plus a token, and the model must get your approval in chat before calling again with it. `auto`: the same two steps, but the model may use the token after reviewing the preview itself. `refuse`: writes are refused on such clients. A client that can show prompts (Claude Code) always gets the real prompt, unless `MCP_CONFIRM_ELICITATION=off`. An unrecognised value is treated as `refuse`. |
|
|
92
|
+
| `MCP_CONFIRM_ELICITATION` | `on` | `off` never sends a confirmation prompt, so every client gets the `MCP_CONFIRM_MODE` path. Set it for a client that declares it can show prompts but never does (the gated call hangs — opencode 2.0.x). Any other value stays `on`, with a stderr warning. |
|
|
91
93
|
| `MCP_CONFIRM_TTL_SECONDS` | `600` | How long a token stays valid. |
|
|
92
94
|
| `MCP_CONFIRM_SECRET` | random per process | Signing key; set it only if tokens must survive a server restart. |
|
|
93
95
|
|
|
@@ -131,12 +133,17 @@ Two limits on `mhlb_checkout` specifically:
|
|
|
131
133
|
There is no "add item X" call. Fetch the model, edit it, send it back whole:
|
|
132
134
|
|
|
133
135
|
1. `mhlb_get_menu` — what is orderable for a student on a date
|
|
134
|
-
2. `mhlb_get_order_form` — the order model to fill in
|
|
135
|
-
3. `mhlb_create_order` — send
|
|
136
|
+
2. `mhlb_get_order_form` — the order model to fill in (its `data` field)
|
|
137
|
+
3. `mhlb_create_order` — send that `data` back (and confirm the preview)
|
|
136
138
|
4. `mhlb_init_checkout` → `mhlb_checkout` — price, then pay
|
|
137
139
|
|
|
138
140
|
Fields omitted from the payload are **cleared**, not preserved.
|
|
139
141
|
|
|
142
|
+
Reads that carry school-, vendor- or menu-authored text (calendar, menus, cart,
|
|
143
|
+
orders, students, transactions, subscriptions, coupon) return
|
|
144
|
+
`{"untrusted_content":true,"note":…,"data":…}`: the payload sits under `data`,
|
|
145
|
+
and the note tells the model to treat that text as data, never as instructions.
|
|
146
|
+
|
|
140
147
|
## Shell skill
|
|
141
148
|
|
|
142
149
|
`skills/myhotlunchbox` covers the same account from a shell with `curl` — no MCP
|
package/dist/auth.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
|
-
import { EdgeBlockedError, McpToolError, detectEdgeBlock, truncateErrorMessage } from '@chrischall/mcp-utils';
|
|
1
|
+
import { EdgeBlockedError, McpToolError, RateLimitError, UnreachableError, detectEdgeBlock, truncateErrorMessage, withAmbientCancellation, } from '@chrischall/mcp-utils';
|
|
2
2
|
import { TokenManager } from '@chrischall/mcp-utils/session';
|
|
3
3
|
import { API_PREFIX, OAUTH_SCOPE } from './config.js';
|
|
4
4
|
import { createTokenCache, reportCacheWriteFailure } from './token-cache.js';
|
|
5
5
|
/** Fallback lifetime when the server omits `expires_in`. */
|
|
6
6
|
const DEFAULT_TOKEN_LIFETIME_S = 3600;
|
|
7
|
+
/** Default per-request timeout for the API and the token endpoint. */
|
|
8
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* The signal every upstream fetch carries: a timeout of its own, combined with
|
|
11
|
+
* the running tool call's cancellation (made ambient by mcp-utils' server
|
|
12
|
+
* wrapper), so a hung upstream cannot hang the tool call and a cancelled call
|
|
13
|
+
* stops waiting.
|
|
14
|
+
*/
|
|
15
|
+
export function requestSignal(timeoutMs) {
|
|
16
|
+
// withAmbientCancellation returns `own` unchanged when there is no ambient
|
|
17
|
+
// signal, so with an own signal in hand it is never undefined.
|
|
18
|
+
return withAmbientCancellation(AbortSignal.timeout(timeoutMs));
|
|
19
|
+
}
|
|
20
|
+
/** Whether a fetch rejection was our own timeout firing (not the caller cancelling). */
|
|
21
|
+
export const isTimeout = (err) => typeof err === 'object' && err !== null && err.name === 'TimeoutError';
|
|
7
22
|
/**
|
|
8
23
|
* The token endpoint answered with an error. Carries the OAuth `error` code so
|
|
9
24
|
* callers can tell a rejected credential (`invalid_grant`) apart from a
|
|
@@ -48,6 +63,7 @@ export function scrubCredentials(text, secrets) {
|
|
|
48
63
|
export class MhlbAuth {
|
|
49
64
|
config;
|
|
50
65
|
fetchImpl;
|
|
66
|
+
timeoutMs;
|
|
51
67
|
manager = null;
|
|
52
68
|
loginInFlight = null;
|
|
53
69
|
/**
|
|
@@ -57,9 +73,17 @@ export class MhlbAuth {
|
|
|
57
73
|
* {@link reset} (mhlb_session_reset) or a restart.
|
|
58
74
|
*/
|
|
59
75
|
credentialRejected = null;
|
|
60
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Set once the API has accepted an access token (from the cache, a login or
|
|
78
|
+
* a refresh) — i.e. answered something other than 401. The manager itself
|
|
79
|
+
* exists before any sign-in has run, so its presence says nothing about a
|
|
80
|
+
* session.
|
|
81
|
+
*/
|
|
82
|
+
sessionEstablished = false;
|
|
83
|
+
constructor(config, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
61
84
|
this.config = config;
|
|
62
85
|
this.fetchImpl = fetchImpl;
|
|
86
|
+
this.timeoutMs = timeoutMs;
|
|
63
87
|
}
|
|
64
88
|
/** Credentials are checked lazily so the server still boots without them. */
|
|
65
89
|
requireCredentials() {
|
|
@@ -72,6 +96,30 @@ export class MhlbAuth {
|
|
|
72
96
|
}
|
|
73
97
|
return { username, password };
|
|
74
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Refuse to send anything — the password included — to an origin that is
|
|
101
|
+
* not https. Plain http is allowed only to loopback, which is what
|
|
102
|
+
* scripts/capture-writes.mjs points MYHOTLUNCHBOX_BASE_URL at. Checked per
|
|
103
|
+
* call rather than at boot so a bad value still lets the server start and
|
|
104
|
+
* surfaces as a tool error.
|
|
105
|
+
*/
|
|
106
|
+
requireSafeOrigin() {
|
|
107
|
+
const { baseUrl } = this.config;
|
|
108
|
+
let url = null;
|
|
109
|
+
try {
|
|
110
|
+
url = new URL(baseUrl);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* reported below */
|
|
114
|
+
}
|
|
115
|
+
const loopback = url !== null && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
116
|
+
if (url?.protocol === 'https:' || (url?.protocol === 'http:' && loopback))
|
|
117
|
+
return;
|
|
118
|
+
throw new McpToolError(`MYHOTLUNCHBOX_BASE_URL (${baseUrl}) is not an https:// origin; refusing to sign in.`, {
|
|
119
|
+
hint: 'MYHOTLUNCHBOX_BASE_URL must be https:// (plain http is allowed only for localhost). ' +
|
|
120
|
+
'Unset it to use https://ordernow.myhotlunchbox.com.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
75
123
|
tokenUrl() {
|
|
76
124
|
return `${this.config.baseUrl}${API_PREFIX}/auth/login`;
|
|
77
125
|
}
|
|
@@ -81,6 +129,7 @@ export class MhlbAuth {
|
|
|
81
129
|
*/
|
|
82
130
|
async postGrant(form) {
|
|
83
131
|
const { password } = this.config;
|
|
132
|
+
const signal = requestSignal(this.timeoutMs);
|
|
84
133
|
let res;
|
|
85
134
|
try {
|
|
86
135
|
res = await this.fetchImpl(this.tokenUrl(), {
|
|
@@ -90,9 +139,17 @@ export class MhlbAuth {
|
|
|
90
139
|
accept: 'application/json',
|
|
91
140
|
},
|
|
92
141
|
body: new URLSearchParams(form).toString(),
|
|
142
|
+
signal,
|
|
93
143
|
});
|
|
94
144
|
}
|
|
95
145
|
catch (cause) {
|
|
146
|
+
if (isTimeout(cause)) {
|
|
147
|
+
throw new McpToolError(`My Hot Lunchbox sign-in timed out after ${Math.round(this.timeoutMs / 1000)}s.`, { hint: 'The sign-in endpoint is slow or down; the credential was never judged. Try again later.', cause });
|
|
148
|
+
}
|
|
149
|
+
// The caller cancelled: let its own reason through rather than calling
|
|
150
|
+
// it a connectivity problem.
|
|
151
|
+
if (signal.aborted)
|
|
152
|
+
throw cause;
|
|
96
153
|
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 });
|
|
97
154
|
}
|
|
98
155
|
const raw = await res.text();
|
|
@@ -110,6 +167,16 @@ export class MhlbAuth {
|
|
|
110
167
|
path: `${API_PREFIX}/auth/login`,
|
|
111
168
|
});
|
|
112
169
|
}
|
|
170
|
+
// A throttle or an outage never judged the password either. Only a 4xx
|
|
171
|
+
// from the token endpoint itself is a verdict on the credential; a 429,
|
|
172
|
+
// a 5xx or a gateway page must not read as "rejected the sign-in",
|
|
173
|
+
// which tells the parent to change a working password and not retry.
|
|
174
|
+
if (res.status === 429) {
|
|
175
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
176
|
+
throw new RateLimitError('My Hot Lunchbox', Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined);
|
|
177
|
+
}
|
|
178
|
+
if (res.status >= 500)
|
|
179
|
+
throw new UnreachableError('My Hot Lunchbox', res.status);
|
|
113
180
|
let parsed = null;
|
|
114
181
|
try {
|
|
115
182
|
parsed = JSON.parse(raw);
|
|
@@ -171,6 +238,7 @@ export class MhlbAuth {
|
|
|
171
238
|
* Single-flight: concurrent first-callers share one login.
|
|
172
239
|
*/
|
|
173
240
|
async ensureManager() {
|
|
241
|
+
this.requireSafeOrigin();
|
|
174
242
|
if (this.manager)
|
|
175
243
|
return this.manager;
|
|
176
244
|
if (this.loginInFlight)
|
|
@@ -234,7 +302,14 @@ export class MhlbAuth {
|
|
|
234
302
|
*/
|
|
235
303
|
async withAuth(call) {
|
|
236
304
|
const manager = await this.ensureManager();
|
|
237
|
-
return manager.withAuth(
|
|
305
|
+
return manager.withAuth(async (accessToken) => {
|
|
306
|
+
const res = await call(accessToken);
|
|
307
|
+
// Only a token the API accepted proves a session; a 401 (even after the
|
|
308
|
+
// one refresh) means the sign-in produced nothing usable.
|
|
309
|
+
if (res.status !== 401)
|
|
310
|
+
this.sessionEstablished = true;
|
|
311
|
+
return res;
|
|
312
|
+
});
|
|
238
313
|
}
|
|
239
314
|
/**
|
|
240
315
|
* Drop the session — in memory AND on disk — and lift a rejected-credential
|
|
@@ -249,14 +324,15 @@ export class MhlbAuth {
|
|
|
249
324
|
this.manager = null;
|
|
250
325
|
this.loginInFlight = null;
|
|
251
326
|
this.credentialRejected = null;
|
|
327
|
+
this.sessionEstablished = false;
|
|
252
328
|
createTokenCache()?.clear();
|
|
253
329
|
}
|
|
254
330
|
/** Raw fetch through the injected implementation (no auth attached). */
|
|
255
331
|
fetch(url, init) {
|
|
256
332
|
return this.fetchImpl(url, init);
|
|
257
333
|
}
|
|
258
|
-
/** Whether a
|
|
334
|
+
/** Whether a sign-in has actually produced an access token in this process. */
|
|
259
335
|
get isAuthenticated() {
|
|
260
|
-
return this.
|
|
336
|
+
return this.sessionEstablished;
|
|
261
337
|
}
|
|
262
338
|
}
|