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.
@@ -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.4"
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.4",
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",
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
- "mcp": "./.mcp.json"
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` to choose where they land (defaults to the working
76
- directory); existing files are never overwritten.
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. An unrecognised value is treated as `refuse`. |
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 it back (and confirm the preview)
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
- constructor(config, fetchImpl = fetch) {
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(call);
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 session has been established in this process. */
334
+ /** Whether a sign-in has actually produced an access token in this process. */
259
335
  get isAuthenticated() {
260
- return this.manager !== null;
336
+ return this.sessionEstablished;
261
337
  }
262
338
  }