myhotlunchbox-mcp 0.2.0 → 0.2.1
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/README.md +32 -12
- package/dist/bundle.js +82 -46
- package/dist/tools/billing.js +12 -10
- package/dist/tools/checkout.js +85 -48
- package/dist/tools/orders.js +39 -9
- package/dist/version.js +1 -1
- package/mint.yaml +9 -6
- package/package.json +4 -2
- package/server.json +2 -2
- package/skills/myhotlunchbox/references/endpoints.md +29 -2
|
@@ -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.2.
|
|
10
|
+
"version": "0.2.1"
|
|
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.2.
|
|
18
|
+
"version": "0.2.1",
|
|
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.2.
|
|
4
|
+
"version": "0.2.1",
|
|
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/README.md
CHANGED
|
@@ -71,18 +71,38 @@ directory); existing files are never overwritten.
|
|
|
71
71
|
Every mutating tool takes `confirm`. Without `confirm: true` it makes **no**
|
|
72
72
|
network call and returns a dry-run preview of exactly what it would send.
|
|
73
73
|
|
|
74
|
-
`mhlb_checkout` charges a real payment method
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
74
|
+
`mhlb_checkout` charges a real payment method. The server prices the charge from
|
|
75
|
+
`orderIds`, so nothing client-side can bind the amount — there is no total in the
|
|
76
|
+
request to check against. `expectedTotal` is therefore **attribution, not a
|
|
77
|
+
guard**: you state what you expected, and it is recorded in the dry run and in
|
|
78
|
+
the result so an unexpected charge is traceable to the call that made it. What
|
|
79
|
+
the tool does refuse outright is paying a non-zero total with no `orderIds`.
|
|
80
|
+
|
|
81
|
+
### Writes: shapes captured, acceptance unverified
|
|
82
|
+
|
|
83
|
+
`npm run capture:writes` runs every mutating tool against a local proxy that
|
|
84
|
+
forwards reads to the real service but answers writes itself, so the payloads
|
|
85
|
+
are built from genuine server models and nothing happens upstream. It also
|
|
86
|
+
proves all 13 refuse to send anything without `confirm: true`.
|
|
87
|
+
|
|
88
|
+
What that established, and corrected: `mhlb_delete_order` and
|
|
89
|
+
`mhlb_unsubscribe_order` take `{orderId, eventDate, studentId, isRepeated,
|
|
90
|
+
isSubscribed}` — not the order model — and checkout takes
|
|
91
|
+
`{orderIds, checkoutType, couponCode, giftCardCode, schoolDonations}`.
|
|
92
|
+
|
|
93
|
+
**What is still unverified is whether the server accepts these bodies.** Shape
|
|
94
|
+
is not acceptance; only a real write shows that, and none has been made. Inspect
|
|
95
|
+
the dry-run preview before confirming, and re-read afterwards — a `200` is not
|
|
96
|
+
proof a write persisted.
|
|
97
|
+
|
|
98
|
+
Two limits on `mhlb_checkout` specifically:
|
|
99
|
+
|
|
100
|
+
- It can only pay with a card **already saved** on the account. Paying with a
|
|
101
|
+
new card needs a Stripe token minted by Stripe.js in a browser, which no
|
|
102
|
+
server-side client can produce.
|
|
103
|
+
- It generates an idempotency key and returns it. If a checkout fails
|
|
104
|
+
ambiguously, retry with that same `idempotencyKey` rather than a fresh call —
|
|
105
|
+
that is what stops a retry becoming a second charge.
|
|
86
106
|
|
|
87
107
|
## Ordering is read-modify-write
|
|
88
108
|
|
package/dist/bundle.js
CHANGED
|
@@ -31891,7 +31891,7 @@ var MhlbClient = class {
|
|
|
31891
31891
|
};
|
|
31892
31892
|
|
|
31893
31893
|
// src/version.ts
|
|
31894
|
-
var VERSION = "0.2.
|
|
31894
|
+
var VERSION = "0.2.1";
|
|
31895
31895
|
|
|
31896
31896
|
// src/tools/_shared.ts
|
|
31897
31897
|
function preview(action, request, notes) {
|
|
@@ -32047,6 +32047,24 @@ function registerCalendarTools(server, client2) {
|
|
|
32047
32047
|
|
|
32048
32048
|
// src/tools/orders.ts
|
|
32049
32049
|
var OrderModel = external_exports.record(external_exports.string(), external_exports.unknown()).describe("The order model, as returned by mhlb_get_order_form / mhlb_get_order, with quantities and options edited.");
|
|
32050
|
+
var OrderRefShape = {
|
|
32051
|
+
orderId: PositiveInt.describe("Order id, from mhlb_get_calendar or mhlb_get_cart."),
|
|
32052
|
+
eventDate: IsoDate.describe("The lunch date of that order (YYYY-MM-DD)."),
|
|
32053
|
+
studentId: PositiveInt.describe("Student the order belongs to."),
|
|
32054
|
+
isRepeated: external_exports.boolean().optional().describe("true acts on the whole recurring series, not just this date. Defaults to false."),
|
|
32055
|
+
isSubscribed: external_exports.boolean().optional().describe(
|
|
32056
|
+
"Whether the order is a subscription. Defaults to false for mhlb_delete_order and true for mhlb_unsubscribe_order, matching what each is for."
|
|
32057
|
+
)
|
|
32058
|
+
};
|
|
32059
|
+
function orderRefBody(ref) {
|
|
32060
|
+
return {
|
|
32061
|
+
orderId: ref.orderId,
|
|
32062
|
+
eventDate: ref.eventDate,
|
|
32063
|
+
studentId: ref.studentId,
|
|
32064
|
+
isRepeated: ref.isRepeated ?? false,
|
|
32065
|
+
isSubscribed: ref.isSubscribed ?? false
|
|
32066
|
+
};
|
|
32067
|
+
}
|
|
32050
32068
|
function registerOrderTools(server, client2) {
|
|
32051
32069
|
server.registerTool(
|
|
32052
32070
|
"mhlb_get_cart",
|
|
@@ -32160,18 +32178,17 @@ function registerOrderTools(server, client2) {
|
|
|
32160
32178
|
{
|
|
32161
32179
|
description: "Cancel a lunch order. If it was already paid for, the refund behaviour is whatever My Hot Lunchbox applies \u2014 this tool does not control it." + UNVERIFIED,
|
|
32162
32180
|
annotations: toolAnnotations({ title: "Delete order", readOnly: false, openWorld: true }),
|
|
32163
|
-
inputSchema: {
|
|
32164
|
-
order: external_exports.record(external_exports.string(), external_exports.unknown()).describe("The order identifier payload, as returned by mhlb_get_order or listed in mhlb_get_cart."),
|
|
32165
|
-
confirm: schemaConfirm
|
|
32166
|
-
}
|
|
32181
|
+
inputSchema: { ...OrderRefShape, confirm: schemaConfirm }
|
|
32167
32182
|
},
|
|
32168
|
-
async ({
|
|
32183
|
+
async ({ confirm, ...ref }) => {
|
|
32184
|
+
const body = orderRefBody(ref);
|
|
32169
32185
|
if (!confirm) {
|
|
32170
|
-
return preview("Delete order", { method: "POST", path: "/event/deleteOrder", body
|
|
32171
|
-
"Cancelling a paid order may or may not refund it \u2014 verify on the site afterwards."
|
|
32186
|
+
return preview("Delete order", { method: "POST", path: "/event/deleteOrder", body }, [
|
|
32187
|
+
"Cancelling a paid order may or may not refund it \u2014 verify on the site afterwards.",
|
|
32188
|
+
"isRepeated: true removes the whole recurring series, not just this date."
|
|
32172
32189
|
]);
|
|
32173
32190
|
}
|
|
32174
|
-
return jsonResult(await client2.write("/event/deleteOrder",
|
|
32191
|
+
return jsonResult(await client2.write("/event/deleteOrder", body));
|
|
32175
32192
|
}
|
|
32176
32193
|
);
|
|
32177
32194
|
}
|
|
@@ -32246,15 +32263,19 @@ function registerBillingTools(server, client2) {
|
|
|
32246
32263
|
{
|
|
32247
32264
|
description: "Stop a recurring subscription for a specific lunch order." + UNVERIFIED,
|
|
32248
32265
|
annotations: toolAnnotations({ title: "Unsubscribe an order", readOnly: false, openWorld: true }),
|
|
32249
|
-
|
|
32250
|
-
|
|
32251
|
-
|
|
32252
|
-
}
|
|
32266
|
+
// Same identifier payload as mhlb_delete_order — the site's order-mixin
|
|
32267
|
+
// routes to whichever endpoint by `isSubscribed`, with one body shape.
|
|
32268
|
+
inputSchema: { ...OrderRefShape, confirm: schemaConfirm }
|
|
32253
32269
|
},
|
|
32254
32270
|
// The upstream route really is spelled `unsubcribeOrder`.
|
|
32255
|
-
async ({
|
|
32256
|
-
|
|
32257
|
-
|
|
32271
|
+
async ({ confirm, ...ref }) => {
|
|
32272
|
+
const body = orderRefBody({ ...ref, isSubscribed: ref.isSubscribed ?? true });
|
|
32273
|
+
if (!confirm) {
|
|
32274
|
+
return preview("Unsubscribe order", { method: "POST", path: "/event/unsubcribeOrder", body }, [
|
|
32275
|
+
"isRepeated: true stops the whole recurring series, not just this date."
|
|
32276
|
+
]);
|
|
32277
|
+
}
|
|
32278
|
+
return jsonResult(await client2.write("/event/unsubcribeOrder", body));
|
|
32258
32279
|
}
|
|
32259
32280
|
);
|
|
32260
32281
|
server.registerTool(
|
|
@@ -32318,6 +32339,7 @@ function registerBillingTools(server, client2) {
|
|
|
32318
32339
|
}
|
|
32319
32340
|
|
|
32320
32341
|
// src/tools/checkout.ts
|
|
32342
|
+
import { randomUUID } from "node:crypto";
|
|
32321
32343
|
function registerCheckoutTools(server, client2) {
|
|
32322
32344
|
server.registerTool(
|
|
32323
32345
|
"mhlb_init_checkout",
|
|
@@ -32325,62 +32347,76 @@ function registerCheckoutTools(server, client2) {
|
|
|
32325
32347
|
description: "Start checkout for the cart: returns the order summary, totals, taxes, applied credits and the available payment methods. This does NOT charge anything \u2014 it is the read step before mhlb_checkout." + UNVERIFIED,
|
|
32326
32348
|
annotations: toolAnnotations({ title: "Initialise checkout", readOnly: false, openWorld: true }),
|
|
32327
32349
|
inputSchema: {
|
|
32328
|
-
|
|
32350
|
+
...CheckoutShape,
|
|
32329
32351
|
confirm: schemaConfirm
|
|
32330
32352
|
}
|
|
32331
32353
|
},
|
|
32332
|
-
async ({
|
|
32354
|
+
async ({ confirm, ...args }) => {
|
|
32355
|
+
const body = checkoutBody(args);
|
|
32333
32356
|
if (!confirm) {
|
|
32334
|
-
return preview("Initialise checkout", { method: "POST", path: "/payment/initCheckout", body
|
|
32357
|
+
return preview("Initialise checkout", { method: "POST", path: "/payment/initCheckout", body }, [
|
|
32335
32358
|
"This step prices the cart and returns payment options. It does not charge a card."
|
|
32336
32359
|
]);
|
|
32337
32360
|
}
|
|
32338
|
-
return jsonResult(await client2.write("/payment/initCheckout",
|
|
32361
|
+
return jsonResult(await client2.write("/payment/initCheckout", body));
|
|
32339
32362
|
}
|
|
32340
32363
|
);
|
|
32341
32364
|
server.registerTool(
|
|
32342
32365
|
"mhlb_checkout",
|
|
32343
32366
|
{
|
|
32344
|
-
description: "PAY for the lunches in the cart. This charges a real payment method on the My Hot Lunchbox account. Run mhlb_init_checkout first, read the total it returns, and pass that
|
|
32367
|
+
description: "PAY for the lunches in the cart. This charges a real payment method on the My Hot Lunchbox account. Run mhlb_init_checkout first, read the total it returns, and pass that figure as expectedTotal. Only a card ALREADY SAVED on the account can be used: paying with a new card needs a Stripe token minted by Stripe.js in a browser, which no server-side client can produce." + UNVERIFIED,
|
|
32345
32368
|
annotations: toolAnnotations({ title: "Pay for cart", readOnly: false, openWorld: true }),
|
|
32346
32369
|
inputSchema: {
|
|
32347
|
-
|
|
32348
|
-
|
|
32370
|
+
...CheckoutShape,
|
|
32371
|
+
availableCredits: external_exports.number().nonnegative().optional().describe("Account credit to apply, as parent_credit_value from mhlb_whoami."),
|
|
32372
|
+
idempotencyKey: external_exports.string().min(1).optional().describe(
|
|
32373
|
+
"Reuse the SAME key when retrying a checkout that may already have gone through \u2014 that is what stops a retry becoming a second charge. Generated automatically when omitted."
|
|
32374
|
+
),
|
|
32375
|
+
expectedTotal: external_exports.number().nonnegative().describe(
|
|
32376
|
+
"The amount you expect to be charged, as mhlb_init_checkout reported it. The server prices the charge from orderIds, so no client-side check can bind the amount \u2014 this is recorded in the dry run and in the result so an unexpected charge is at least attributable."
|
|
32377
|
+
),
|
|
32349
32378
|
confirm: schemaConfirm
|
|
32350
32379
|
}
|
|
32351
32380
|
},
|
|
32352
|
-
async ({
|
|
32353
|
-
const
|
|
32381
|
+
async ({ availableCredits, idempotencyKey, expectedTotal, confirm, ...args }) => {
|
|
32382
|
+
const key = idempotencyKey ?? `${randomUUID()}-${Date.now()}`;
|
|
32383
|
+
const body = {
|
|
32384
|
+
...checkoutBody(args),
|
|
32385
|
+
...availableCredits !== void 0 ? { availableCredits } : {},
|
|
32386
|
+
idempotencyKey: key
|
|
32387
|
+
};
|
|
32354
32388
|
if (!confirm) {
|
|
32355
|
-
return preview("Pay for cart", { method: "POST", path: "/payment/checkout", body
|
|
32389
|
+
return preview("Pay for cart", { method: "POST", path: "/payment/checkout", body }, [
|
|
32356
32390
|
`This CHARGES a payment method. Expected total: ${expectedTotal}.`,
|
|
32357
|
-
|
|
32391
|
+
"No stripeToken is sent, so this can only pay with a card already saved on the account. Paying with a NEW card needs a Stripe token minted by Stripe.js in a browser, which no server-side client can produce \u2014 do that on the site.",
|
|
32392
|
+
`Idempotency key for this attempt: ${key}. Reuse it if you retry.`
|
|
32358
32393
|
]);
|
|
32359
32394
|
}
|
|
32360
|
-
if (
|
|
32361
|
-
|
|
32362
|
-
|
|
32363
|
-
reason: "expectedTotal does not match the total in the payment payload.",
|
|
32364
|
-
expectedTotal,
|
|
32365
|
-
payloadTotal: stated,
|
|
32366
|
-
hint: "Re-run mhlb_init_checkout to reprice the cart, then retry with the total it reports."
|
|
32395
|
+
if (expectedTotal > 0 && args.orderIds.length === 0) {
|
|
32396
|
+
throw new McpToolError("Refusing to pay: no orderIds were given.", {
|
|
32397
|
+
hint: "Pass the orderIds mhlb_init_checkout priced, so the charge is bound to specific orders."
|
|
32367
32398
|
});
|
|
32368
32399
|
}
|
|
32369
|
-
|
|
32400
|
+
const result = await client2.write("/payment/checkout", body);
|
|
32401
|
+
return jsonResult({ result, expectedTotal, idempotencyKey: key });
|
|
32370
32402
|
}
|
|
32371
32403
|
);
|
|
32372
32404
|
}
|
|
32373
|
-
|
|
32374
|
-
|
|
32375
|
-
|
|
32376
|
-
|
|
32377
|
-
|
|
32378
|
-
|
|
32379
|
-
|
|
32380
|
-
|
|
32381
|
-
|
|
32382
|
-
|
|
32383
|
-
|
|
32405
|
+
var CheckoutShape = {
|
|
32406
|
+
orderIds: external_exports.array(PositiveInt).describe("Ids of the orders to pay for, from mhlb_get_cart."),
|
|
32407
|
+
checkoutType: external_exports.union([external_exports.number().int(), external_exports.string()]).optional().describe("Payment method type, as mhlb_init_checkout reports it. Omit to let the server default."),
|
|
32408
|
+
couponCode: external_exports.string().optional().describe("Coupon code to apply to this checkout."),
|
|
32409
|
+
giftCardCode: external_exports.string().optional().describe("Gift card code to apply to this checkout."),
|
|
32410
|
+
schoolDonations: external_exports.unknown().optional().describe("School donation selections, as returned by mhlb_init_checkout.")
|
|
32411
|
+
};
|
|
32412
|
+
function checkoutBody(args) {
|
|
32413
|
+
return {
|
|
32414
|
+
orderIds: args.orderIds,
|
|
32415
|
+
checkoutType: args.checkoutType ?? null,
|
|
32416
|
+
couponCode: args.couponCode ?? null,
|
|
32417
|
+
giftCardCode: args.giftCardCode ?? null,
|
|
32418
|
+
schoolDonations: args.schoolDonations ?? null
|
|
32419
|
+
};
|
|
32384
32420
|
}
|
|
32385
32421
|
|
|
32386
32422
|
// src/tools/reports.ts
|
package/dist/tools/billing.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { toolAnnotations, PositiveInt, NonEmptyString, schemaConfirm } from '@chrischall/mcp-utils';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { jsonResult, preview, UNVERIFIED } from './_shared.js';
|
|
4
|
+
import { OrderRefShape, orderRefBody } from './orders.js';
|
|
4
5
|
export function registerBillingTools(server, client) {
|
|
5
6
|
server.registerTool('mhlb_list_transactions', {
|
|
6
7
|
description: 'List payment transactions on the account — date, amount, and what was paid for.',
|
|
@@ -44,18 +45,19 @@ export function registerBillingTools(server, client) {
|
|
|
44
45
|
server.registerTool('mhlb_unsubscribe_order', {
|
|
45
46
|
description: 'Stop a recurring subscription for a specific lunch order.' + UNVERIFIED,
|
|
46
47
|
annotations: toolAnnotations({ title: 'Unsubscribe an order', readOnly: false, openWorld: true }),
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.describe('The subscription identifier payload, from mhlb_list_subscriptions.'),
|
|
51
|
-
confirm: schemaConfirm,
|
|
52
|
-
},
|
|
48
|
+
// Same identifier payload as mhlb_delete_order — the site's order-mixin
|
|
49
|
+
// routes to whichever endpoint by `isSubscribed`, with one body shape.
|
|
50
|
+
inputSchema: { ...OrderRefShape, confirm: schemaConfirm },
|
|
53
51
|
},
|
|
54
52
|
// The upstream route really is spelled `unsubcribeOrder`.
|
|
55
|
-
async ({
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
async ({ confirm, ...ref }) => {
|
|
54
|
+
const body = orderRefBody({ ...ref, isSubscribed: ref.isSubscribed ?? true });
|
|
55
|
+
if (!confirm) {
|
|
56
|
+
return preview('Unsubscribe order', { method: 'POST', path: '/event/unsubcribeOrder', body }, [
|
|
57
|
+
'isRepeated: true stops the whole recurring series, not just this date.',
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
return jsonResult(await client.write('/event/unsubcribeOrder', body));
|
|
59
61
|
});
|
|
60
62
|
server.registerTool('mhlb_list_gift_cards', {
|
|
61
63
|
description: 'List gift cards on the account — codes, balances and status.',
|
package/dist/tools/checkout.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { McpToolError, toolAnnotations, PositiveInt, schemaConfirm } from '@chrischall/mcp-utils';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { jsonResult, preview, UNVERIFIED } from './_shared.js';
|
|
4
5
|
/**
|
|
5
6
|
* Checkout is the only pair of tools that moves money. Both are confirm-gated
|
|
6
|
-
* like every other write
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* like every other write.
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately no amount check. The server prices the charge from
|
|
10
|
+
* `orderIds`; the request carries no total, so anything compared here would be
|
|
11
|
+
* the caller's own input against itself. `expectedTotal` is attribution — it is
|
|
12
|
+
* echoed in the dry run and returned with the result, so a surprising charge is
|
|
13
|
+
* traceable to the call that made it. The one real refusal is paying a non-zero
|
|
14
|
+
* total with no `orderIds`.
|
|
10
15
|
*/
|
|
11
16
|
export function registerCheckoutTools(server, client) {
|
|
12
17
|
server.registerTool('mhlb_init_checkout', {
|
|
@@ -15,73 +20,105 @@ export function registerCheckoutTools(server, client) {
|
|
|
15
20
|
UNVERIFIED,
|
|
16
21
|
annotations: toolAnnotations({ title: 'Initialise checkout', readOnly: false, openWorld: true }),
|
|
17
22
|
inputSchema: {
|
|
18
|
-
|
|
19
|
-
.record(z.string(), z.unknown())
|
|
20
|
-
.describe('The checkout request payload — the orders to pay for, from mhlb_get_cart.'),
|
|
23
|
+
...CheckoutShape,
|
|
21
24
|
confirm: schemaConfirm,
|
|
22
25
|
},
|
|
23
|
-
}, async ({
|
|
26
|
+
}, async ({ confirm, ...args }) => {
|
|
27
|
+
const body = checkoutBody(args);
|
|
24
28
|
if (!confirm) {
|
|
25
|
-
return preview('Initialise checkout', { method: 'POST', path: '/payment/initCheckout', body
|
|
29
|
+
return preview('Initialise checkout', { method: 'POST', path: '/payment/initCheckout', body }, [
|
|
26
30
|
'This step prices the cart and returns payment options. It does not charge a card.',
|
|
27
31
|
]);
|
|
28
32
|
}
|
|
29
|
-
return jsonResult(await client.write('/payment/initCheckout',
|
|
33
|
+
return jsonResult(await client.write('/payment/initCheckout', body));
|
|
30
34
|
});
|
|
31
35
|
server.registerTool('mhlb_checkout', {
|
|
32
36
|
description: 'PAY for the lunches in the cart. This charges a real payment method on the My Hot Lunchbox account. ' +
|
|
33
|
-
'Run mhlb_init_checkout first, read the total it returns, and pass that
|
|
34
|
-
'
|
|
37
|
+
'Run mhlb_init_checkout first, read the total it returns, and pass that figure as expectedTotal. ' +
|
|
38
|
+
'Only a card ALREADY SAVED on the account can be used: paying with a new card needs a Stripe token ' +
|
|
39
|
+
'minted by Stripe.js in a browser, which no server-side client can produce.' + UNVERIFIED,
|
|
35
40
|
annotations: toolAnnotations({ title: 'Pay for cart', readOnly: false, openWorld: true }),
|
|
36
41
|
inputSchema: {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
42
|
+
...CheckoutShape,
|
|
43
|
+
availableCredits: z
|
|
44
|
+
.number()
|
|
45
|
+
.nonnegative()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe('Account credit to apply, as parent_credit_value from mhlb_whoami.'),
|
|
48
|
+
idempotencyKey: z
|
|
49
|
+
.string()
|
|
50
|
+
.min(1)
|
|
51
|
+
.optional()
|
|
52
|
+
.describe('Reuse the SAME key when retrying a checkout that may already have gone through — that is what ' +
|
|
53
|
+
'stops a retry becoming a second charge. Generated automatically when omitted.'),
|
|
40
54
|
expectedTotal: z
|
|
41
55
|
.number()
|
|
42
56
|
.nonnegative()
|
|
43
|
-
.describe('The amount you expect to be charged,
|
|
57
|
+
.describe('The amount you expect to be charged, as mhlb_init_checkout reported it. The server prices the ' +
|
|
58
|
+
'charge from orderIds, so no client-side check can bind the amount — this is recorded in the ' +
|
|
59
|
+
'dry run and in the result so an unexpected charge is at least attributable.'),
|
|
44
60
|
confirm: schemaConfirm,
|
|
45
61
|
},
|
|
46
|
-
}, async ({
|
|
47
|
-
|
|
62
|
+
}, async ({ availableCredits, idempotencyKey, expectedTotal, confirm, ...args }) => {
|
|
63
|
+
// The site generates this client-side as `${randomUUID()}-${Date.now()}`
|
|
64
|
+
// (form-mixin's getKey) and holds it in session storage across retries.
|
|
65
|
+
const key = idempotencyKey ?? `${randomUUID()}-${Date.now()}`;
|
|
66
|
+
const body = {
|
|
67
|
+
...checkoutBody(args),
|
|
68
|
+
...(availableCredits !== undefined ? { availableCredits } : {}),
|
|
69
|
+
idempotencyKey: key,
|
|
70
|
+
};
|
|
48
71
|
if (!confirm) {
|
|
49
|
-
return preview('Pay for cart', { method: 'POST', path: '/payment/checkout', body
|
|
72
|
+
return preview('Pay for cart', { method: 'POST', path: '/payment/checkout', body }, [
|
|
50
73
|
`This CHARGES a payment method. Expected total: ${expectedTotal}.`,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
'No stripeToken is sent, so this can only pay with a card already saved on the account. ' +
|
|
75
|
+
'Paying with a NEW card needs a Stripe token minted by Stripe.js in a browser, which no ' +
|
|
76
|
+
'server-side client can produce — do that on the site.',
|
|
77
|
+
`Idempotency key for this attempt: ${key}. Reuse it if you retry.`,
|
|
54
78
|
]);
|
|
55
79
|
}
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
reason: 'expectedTotal does not match the total in the payment payload.',
|
|
60
|
-
expectedTotal,
|
|
61
|
-
payloadTotal: stated,
|
|
62
|
-
hint: 'Re-run mhlb_init_checkout to reprice the cart, then retry with the total it reports.',
|
|
80
|
+
if (expectedTotal > 0 && args.orderIds.length === 0) {
|
|
81
|
+
throw new McpToolError('Refusing to pay: no orderIds were given.', {
|
|
82
|
+
hint: 'Pass the orderIds mhlb_init_checkout priced, so the charge is bound to specific orders.',
|
|
63
83
|
});
|
|
64
84
|
}
|
|
65
|
-
|
|
85
|
+
// Nested, not spread: the response shape is unverified, so spreading it
|
|
86
|
+
// would mangle a non-object and would let `expectedTotal`/`idempotencyKey`
|
|
87
|
+
// silently shadow same-named server fields.
|
|
88
|
+
const result = await client.write('/payment/checkout', body);
|
|
89
|
+
return jsonResult({ result, expectedTotal, idempotencyKey: key });
|
|
66
90
|
});
|
|
67
91
|
}
|
|
68
|
-
/** Amounts within a cent are equal — the API and the caller may round differently. */
|
|
69
|
-
function nearlyEqual(a, b) {
|
|
70
|
-
return Math.abs(a - b) < 0.005;
|
|
71
|
-
}
|
|
72
92
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
93
|
+
* The checkout payload the site actually sends, captured from its own
|
|
94
|
+
* `checkout` route chunk:
|
|
95
|
+
* initCheckout {orderIds, checkoutType, couponCode, giftCardCode, schoolDonations}
|
|
96
|
+
* checkout … the same, plus {availableCredits, idempotencyKey, stripeToken}
|
|
97
|
+
*
|
|
98
|
+
* `stripeToken` is deliberately absent here: the site only sets it when paying
|
|
99
|
+
* by a NEW card, and it comes from `Stripe.createToken` in the browser. With a
|
|
100
|
+
* card already saved on the account it is left undefined, which is the only
|
|
101
|
+
* case a server-side client can serve.
|
|
76
102
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
const CheckoutShape = {
|
|
104
|
+
orderIds: z
|
|
105
|
+
.array(PositiveInt)
|
|
106
|
+
.describe('Ids of the orders to pay for, from mhlb_get_cart.'),
|
|
107
|
+
checkoutType: z
|
|
108
|
+
.union([z.number().int(), z.string()])
|
|
109
|
+
.optional()
|
|
110
|
+
.describe('Payment method type, as mhlb_init_checkout reports it. Omit to let the server default.'),
|
|
111
|
+
couponCode: z.string().optional().describe('Coupon code to apply to this checkout.'),
|
|
112
|
+
giftCardCode: z.string().optional().describe('Gift card code to apply to this checkout.'),
|
|
113
|
+
schoolDonations: z.unknown().optional().describe('School donation selections, as returned by mhlb_init_checkout.'),
|
|
114
|
+
};
|
|
115
|
+
function checkoutBody(args) {
|
|
116
|
+
// The site sends these keys explicitly with null rather than omitting them.
|
|
117
|
+
return {
|
|
118
|
+
orderIds: args.orderIds,
|
|
119
|
+
checkoutType: args.checkoutType ?? null,
|
|
120
|
+
couponCode: args.couponCode ?? null,
|
|
121
|
+
giftCardCode: args.giftCardCode ?? null,
|
|
122
|
+
schoolDonations: args.schoolDonations ?? null,
|
|
123
|
+
};
|
|
87
124
|
}
|
package/dist/tools/orders.js
CHANGED
|
@@ -11,6 +11,39 @@ import { jsonResult, preview, UNVERIFIED } from './_shared.js';
|
|
|
11
11
|
const OrderModel = z
|
|
12
12
|
.record(z.string(), z.unknown())
|
|
13
13
|
.describe('The order model, as returned by mhlb_get_order_form / mhlb_get_order, with quantities and options edited.');
|
|
14
|
+
/**
|
|
15
|
+
* Cancelling and unsubscribing take a small identifier payload — NOT the order
|
|
16
|
+
* model the create/edit pair round-trips. Captured from the site's own
|
|
17
|
+
* `order-mixin`, which builds exactly
|
|
18
|
+
* `{orderId, isRepeated, eventDate, studentId, isSubscribed}`.
|
|
19
|
+
*
|
|
20
|
+
* Spelling this out rather than accepting an opaque record is the point: an
|
|
21
|
+
* agent handed "the order payload" sends the 40-field model, which is not what
|
|
22
|
+
* these endpoints read.
|
|
23
|
+
*/
|
|
24
|
+
export const OrderRefShape = {
|
|
25
|
+
orderId: PositiveInt.describe('Order id, from mhlb_get_calendar or mhlb_get_cart.'),
|
|
26
|
+
eventDate: IsoDate.describe('The lunch date of that order (YYYY-MM-DD).'),
|
|
27
|
+
studentId: PositiveInt.describe('Student the order belongs to.'),
|
|
28
|
+
isRepeated: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('true acts on the whole recurring series, not just this date. Defaults to false.'),
|
|
32
|
+
isSubscribed: z
|
|
33
|
+
.boolean()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Whether the order is a subscription. Defaults to false for mhlb_delete_order and true for ' +
|
|
36
|
+
'mhlb_unsubscribe_order, matching what each is for.'),
|
|
37
|
+
};
|
|
38
|
+
export function orderRefBody(ref) {
|
|
39
|
+
return {
|
|
40
|
+
orderId: ref.orderId,
|
|
41
|
+
eventDate: ref.eventDate,
|
|
42
|
+
studentId: ref.studentId,
|
|
43
|
+
isRepeated: ref.isRepeated ?? false,
|
|
44
|
+
isSubscribed: ref.isSubscribed ?? false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
14
47
|
export function registerOrderTools(server, client) {
|
|
15
48
|
server.registerTool('mhlb_get_cart', {
|
|
16
49
|
description: 'Get the shopping cart — lunches added but not yet paid for. Filter by order status and ordering period, ' +
|
|
@@ -97,18 +130,15 @@ export function registerOrderTools(server, client) {
|
|
|
97
130
|
description: 'Cancel a lunch order. If it was already paid for, the refund behaviour is whatever My Hot Lunchbox ' +
|
|
98
131
|
'applies — this tool does not control it.' + UNVERIFIED,
|
|
99
132
|
annotations: toolAnnotations({ title: 'Delete order', readOnly: false, openWorld: true }),
|
|
100
|
-
inputSchema: {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
.describe('The order identifier payload, as returned by mhlb_get_order or listed in mhlb_get_cart.'),
|
|
104
|
-
confirm: schemaConfirm,
|
|
105
|
-
},
|
|
106
|
-
}, async ({ order, confirm }) => {
|
|
133
|
+
inputSchema: { ...OrderRefShape, confirm: schemaConfirm },
|
|
134
|
+
}, async ({ confirm, ...ref }) => {
|
|
135
|
+
const body = orderRefBody(ref);
|
|
107
136
|
if (!confirm) {
|
|
108
|
-
return preview('Delete order', { method: 'POST', path: '/event/deleteOrder', body
|
|
137
|
+
return preview('Delete order', { method: 'POST', path: '/event/deleteOrder', body }, [
|
|
109
138
|
'Cancelling a paid order may or may not refund it — verify on the site afterwards.',
|
|
139
|
+
'isRepeated: true removes the whole recurring series, not just this date.',
|
|
110
140
|
]);
|
|
111
141
|
}
|
|
112
|
-
return jsonResult(await client.write('/event/deleteOrder',
|
|
142
|
+
return jsonResult(await client.write('/event/deleteOrder', body));
|
|
113
143
|
});
|
|
114
144
|
}
|
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.
|
|
2
|
+
export const VERSION = '0.2.1'; // x-release-please-version
|
package/mint.yaml
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
# How this MCP wants to be hosted on mcp-host
|
|
1
|
+
# How this MCP wants to be hosted on mcp-host — the file its registration
|
|
2
|
+
# preview reads to fill the wizard in, instead of someone guessing from the
|
|
3
|
+
# README (mcp-host's own docs describe the format).
|
|
2
4
|
#
|
|
3
|
-
# The FILENAME is load-bearing:
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# The FILENAME is load-bearing: the reader matches it exactly, and a name it
|
|
6
|
+
# does not expect is not an error — it is simply never found, and the preview
|
|
7
|
+
# falls back to inference with nothing to notice. The reader accepts the older
|
|
8
|
+
# `mcp-host.yaml` as well, so the two names coexist. If this file ever looks
|
|
9
|
+
# ignored, check the manifest-filename constant in mcp-host's core package
|
|
10
|
+
# before looking anywhere else.
|
|
8
11
|
#
|
|
9
12
|
# This file is a PROPOSAL, never an application. It is read at the resolved pin
|
|
10
13
|
# by the registration preview and rendered for a human; nothing here changes a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myhotlunchbox-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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>",
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
"typecheck": "tsc --noEmit",
|
|
41
41
|
"test": "vitest run",
|
|
42
42
|
"test:watch": "vitest",
|
|
43
|
-
"test:coverage": "vitest run --coverage"
|
|
43
|
+
"test:coverage": "vitest run --coverage",
|
|
44
|
+
"verify:reads": "node scripts/verify-reads.mjs",
|
|
45
|
+
"capture:writes": "node scripts/capture-writes.mjs"
|
|
44
46
|
},
|
|
45
47
|
"dependencies": {
|
|
46
48
|
"@chrischall/mcp-utils": "^0.15.0",
|
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.2.
|
|
9
|
+
"version": "0.2.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "myhotlunchbox-mcp",
|
|
14
|
-
"version": "0.2.
|
|
14
|
+
"version": "0.2.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -128,7 +128,7 @@ model, edit it, post it back whole, then re-read to confirm.
|
|
|
128
128
|
|---|---|---|
|
|
129
129
|
| Place an order | `GET /event/createOrder?eventId=&studentId=` | `POST /event/createOrder` |
|
|
130
130
|
| Change an order | `GET /event/editOrder?orderId=` | `POST /event/editOrder` |
|
|
131
|
-
| Cancel an order | — | `POST /event/deleteOrder` |
|
|
131
|
+
| Cancel an order | — | `POST /event/deleteOrder` — body below, **not** the order model |
|
|
132
132
|
| Add a student | `GET /parent/createChild` | `POST /parent/createChild` |
|
|
133
133
|
| Edit a student | `GET /parent/editChild?childId=` | `POST /parent/editChild` |
|
|
134
134
|
| Remove a student | — | `POST /parent/deleteChild?id=` |
|
|
@@ -136,12 +136,39 @@ model, edit it, post it back whole, then re-read to confirm.
|
|
|
136
136
|
| Apply a coupon | — | `POST /parent/applyCoupon?couponCode=` |
|
|
137
137
|
| Remove the coupon | — | `POST /parent/removeCoupon` |
|
|
138
138
|
| Toggle subscriptions | `GET /event/subscription` | `POST /parent/changeSubscriptionStatus?isEnableSubscription=` |
|
|
139
|
-
| Stop one subscription | `GET /event/upcomingSubscriptions` | `POST /event/unsubcribeOrder` |
|
|
139
|
+
| Stop one subscription | `GET /event/upcomingSubscriptions` | `POST /event/unsubcribeOrder` — same body as deleteOrder |
|
|
140
140
|
| Price the cart | — | `POST /payment/initCheckout` |
|
|
141
141
|
| **Pay** | — | `POST /payment/checkout` |
|
|
142
142
|
|
|
143
143
|
`POST /event/unsubcribeOrder` is spelled that way upstream — the typo is theirs.
|
|
144
144
|
|
|
145
|
+
Cancelling and unsubscribing take a small identifier payload, **not** the order
|
|
146
|
+
model that create/edit round-trip. Captured from the site's own `order-mixin`:
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
# isRepeated: true acts on the whole recurring series, not just this date.
|
|
150
|
+
mhlb_post /event/deleteOrder \
|
|
151
|
+
'{"orderId":17284377,"eventDate":"2026-08-26","studentId":111627,"isRepeated":false,"isSubscribed":false}'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Checkout takes `{orderIds, checkoutType, couponCode, giftCardCode, schoolDonations}`,
|
|
155
|
+
with the nulls sent explicitly, plus `{availableCredits, idempotencyKey, stripeToken}`
|
|
156
|
+
on `/payment/checkout`:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
mhlb_post /payment/initCheckout \
|
|
160
|
+
'{"orderIds":[123],"checkoutType":null,"couponCode":null,"giftCardCode":null,"schoolDonations":null}'
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Two things about paying:
|
|
164
|
+
|
|
165
|
+
- **`stripeToken` cannot be produced outside a browser.** The site mints it with
|
|
166
|
+
Stripe.js, and only when paying by a NEW card. Server-side you can only pay
|
|
167
|
+
with a card already saved on the account.
|
|
168
|
+
- **`idempotencyKey` is yours to generate** — the site uses
|
|
169
|
+
`"$(uuidgen | tr A-Z a-z)-$(date +%s000)"`. Reuse the SAME key when retrying an
|
|
170
|
+
ambiguous checkout; a fresh one risks a second charge.
|
|
171
|
+
|
|
145
172
|
`POST /payment/checkout` charges a real payment method. Price with
|
|
146
173
|
`initCheckout` first, read the total it returns, and confirm that figure before
|
|
147
174
|
paying.
|