myhotlunchbox-mcp 0.2.1 → 0.4.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/README.md +3 -1
- package/dist/auth.js +17 -5
- package/dist/bundle.js +584 -54
- package/dist/index.js +2 -0
- package/dist/token-cache.js +63 -0
- package/dist/tools/health.js +65 -0
- package/dist/version.js +1 -1
- package/mint.yaml +24 -4
- package/package.json +4 -4
- package/server.json +16 -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.
|
|
10
|
+
"version": "0.4.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.4.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.4.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/README.md
CHANGED
|
@@ -38,10 +38,12 @@ still works; the configuration error surfaces on the first tool call.
|
|
|
38
38
|
|
|
39
39
|
## Tools
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
35 tools, all prefixed `mhlb_`. All 20 read tools are verified live against a real parent account (`node scripts/verify-reads.mjs`); the 14 write tools are not — see below.
|
|
42
42
|
|
|
43
43
|
**Account** — `mhlb_whoami`, `mhlb_session_reset`
|
|
44
44
|
|
|
45
|
+
**Health** — `mhlb_healthcheck` (is this connector working? reports whether the credential resolved, whether My Hot Lunchbox accepted it, and what to fix — unlike `mhlb_whoami`, which throws instead of answering)
|
|
46
|
+
|
|
45
47
|
**Students** — `mhlb_list_students`, `mhlb_get_student_form`,
|
|
46
48
|
`mhlb_new_student_form`, `mhlb_create_student`, `mhlb_update_student`,
|
|
47
49
|
`mhlb_delete_student`
|
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpToolError, truncateErrorMessage } from '@chrischall/mcp-utils';
|
|
2
2
|
import { TokenManager } from '@chrischall/mcp-utils/session';
|
|
3
3
|
import { API_PREFIX, OAUTH_SCOPE } from './config.js';
|
|
4
|
+
import { createTokenCache, reportCacheWriteFailure } from './token-cache.js';
|
|
4
5
|
/** Fallback lifetime when the server omits `expires_in`. */
|
|
5
6
|
const DEFAULT_TOKEN_LIFETIME_S = 3600;
|
|
6
7
|
/**
|
|
@@ -132,13 +133,24 @@ export class MhlbAuth {
|
|
|
132
133
|
if (this.loginInFlight)
|
|
133
134
|
return this.loginInFlight;
|
|
134
135
|
this.loginInFlight = (async () => {
|
|
135
|
-
const body = await this.passwordLogin();
|
|
136
136
|
const manager = new TokenManager({
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
// The FUNCTION form, so the cache is consulted before the password
|
|
138
|
+
// grant runs at all — the eager object form skips persistence, which
|
|
139
|
+
// would mean a cache that is written and never read.
|
|
140
|
+
initial: async () => {
|
|
141
|
+
const body = await this.passwordLogin();
|
|
142
|
+
return {
|
|
143
|
+
accessToken: this.assertAccessToken(body),
|
|
144
|
+
refreshToken: body.refresh_token,
|
|
145
|
+
expiresAt: MhlbAuth.expiryOf(body),
|
|
146
|
+
};
|
|
141
147
|
},
|
|
148
|
+
persistence: createTokenCache() ?? undefined,
|
|
149
|
+
onPersistError: reportCacheWriteFailure,
|
|
150
|
+
// `refresh` below already recovers from a revoked refresh token by
|
|
151
|
+
// falling back to a full login, so the library's own re-mint-on-revoked
|
|
152
|
+
// recovery would only add a third attempt after both have failed.
|
|
153
|
+
isRefreshRevoked: () => false,
|
|
142
154
|
refresh: async (refreshToken) => {
|
|
143
155
|
let next;
|
|
144
156
|
try {
|
package/dist/bundle.js
CHANGED
|
@@ -2984,7 +2984,7 @@ var require_compile = __commonJS({
|
|
|
2984
2984
|
const schOrFunc = root.refs[ref];
|
|
2985
2985
|
if (schOrFunc)
|
|
2986
2986
|
return schOrFunc;
|
|
2987
|
-
let _sch =
|
|
2987
|
+
let _sch = resolve3.call(this, root, ref);
|
|
2988
2988
|
if (_sch === void 0) {
|
|
2989
2989
|
const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
|
|
2990
2990
|
const { schemaId } = this.opts;
|
|
@@ -3011,7 +3011,7 @@ var require_compile = __commonJS({
|
|
|
3011
3011
|
function sameSchemaEnv(s1, s2) {
|
|
3012
3012
|
return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
|
|
3013
3013
|
}
|
|
3014
|
-
function
|
|
3014
|
+
function resolve3(root, ref) {
|
|
3015
3015
|
let sch;
|
|
3016
3016
|
while (typeof (sch = this.refs[ref]) == "string")
|
|
3017
3017
|
ref = sch;
|
|
@@ -3836,7 +3836,7 @@ var require_fast_uri = __commonJS({
|
|
|
3836
3836
|
}
|
|
3837
3837
|
return uri;
|
|
3838
3838
|
}
|
|
3839
|
-
function
|
|
3839
|
+
function resolve3(baseURI, relativeURI, options) {
|
|
3840
3840
|
const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
|
|
3841
3841
|
const {
|
|
3842
3842
|
parsed: baseParsed,
|
|
@@ -4198,7 +4198,7 @@ var require_fast_uri = __commonJS({
|
|
|
4198
4198
|
var fastUri = {
|
|
4199
4199
|
SCHEMES,
|
|
4200
4200
|
normalize,
|
|
4201
|
-
resolve:
|
|
4201
|
+
resolve: resolve3,
|
|
4202
4202
|
resolveComponent,
|
|
4203
4203
|
equal,
|
|
4204
4204
|
serialize,
|
|
@@ -29145,7 +29145,7 @@ var Protocol = class {
|
|
|
29145
29145
|
return;
|
|
29146
29146
|
}
|
|
29147
29147
|
const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
|
|
29148
|
-
await new Promise((
|
|
29148
|
+
await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
|
|
29149
29149
|
options?.signal?.throwIfAborted();
|
|
29150
29150
|
}
|
|
29151
29151
|
} catch (error51) {
|
|
@@ -29162,7 +29162,7 @@ var Protocol = class {
|
|
|
29162
29162
|
*/
|
|
29163
29163
|
request(request, resultSchema, options) {
|
|
29164
29164
|
const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
|
|
29165
|
-
return new Promise((
|
|
29165
|
+
return new Promise((resolve3, reject) => {
|
|
29166
29166
|
const earlyReject = (error51) => {
|
|
29167
29167
|
reject(error51);
|
|
29168
29168
|
};
|
|
@@ -29240,7 +29240,7 @@ var Protocol = class {
|
|
|
29240
29240
|
if (!parseResult.success) {
|
|
29241
29241
|
reject(parseResult.error);
|
|
29242
29242
|
} else {
|
|
29243
|
-
|
|
29243
|
+
resolve3(parseResult.data);
|
|
29244
29244
|
}
|
|
29245
29245
|
} catch (error51) {
|
|
29246
29246
|
reject(error51);
|
|
@@ -29501,12 +29501,12 @@ var Protocol = class {
|
|
|
29501
29501
|
}
|
|
29502
29502
|
} catch {
|
|
29503
29503
|
}
|
|
29504
|
-
return new Promise((
|
|
29504
|
+
return new Promise((resolve3, reject) => {
|
|
29505
29505
|
if (signal.aborted) {
|
|
29506
29506
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
29507
29507
|
return;
|
|
29508
29508
|
}
|
|
29509
|
-
const timeoutId = setTimeout(
|
|
29509
|
+
const timeoutId = setTimeout(resolve3, interval);
|
|
29510
29510
|
signal.addEventListener("abort", () => {
|
|
29511
29511
|
clearTimeout(timeoutId);
|
|
29512
29512
|
reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
|
|
@@ -30597,7 +30597,7 @@ var McpServer = class {
|
|
|
30597
30597
|
let task = createTaskResult.task;
|
|
30598
30598
|
const pollInterval = task.pollInterval ?? 5e3;
|
|
30599
30599
|
while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
|
|
30600
|
-
await new Promise((
|
|
30600
|
+
await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
|
|
30601
30601
|
const updatedTask = await extra.taskStore.getTask(taskId);
|
|
30602
30602
|
if (!updatedTask) {
|
|
30603
30603
|
throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
|
|
@@ -31261,12 +31261,12 @@ var StdioServerTransport = class {
|
|
|
31261
31261
|
this.onclose?.();
|
|
31262
31262
|
}
|
|
31263
31263
|
send(message) {
|
|
31264
|
-
return new Promise((
|
|
31264
|
+
return new Promise((resolve3) => {
|
|
31265
31265
|
const json2 = serializeMessage(message);
|
|
31266
31266
|
if (this._stdout.write(json2)) {
|
|
31267
|
-
|
|
31267
|
+
resolve3();
|
|
31268
31268
|
} else {
|
|
31269
|
-
this._stdout.once("drain",
|
|
31269
|
+
this._stdout.once("drain", resolve3);
|
|
31270
31270
|
}
|
|
31271
31271
|
});
|
|
31272
31272
|
}
|
|
@@ -31343,6 +31343,11 @@ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
|
|
|
31343
31343
|
return redacted;
|
|
31344
31344
|
return `${redacted.slice(0, max)}\u2026 [truncated]`;
|
|
31345
31345
|
}
|
|
31346
|
+
function messageOf(err) {
|
|
31347
|
+
if (err instanceof Error)
|
|
31348
|
+
return err.message;
|
|
31349
|
+
return String(err);
|
|
31350
|
+
}
|
|
31346
31351
|
|
|
31347
31352
|
// node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
31348
31353
|
function textResult(data) {
|
|
@@ -31440,6 +31445,20 @@ function readEnvVar(key, opts = {}) {
|
|
|
31440
31445
|
}
|
|
31441
31446
|
return opts.default;
|
|
31442
31447
|
}
|
|
31448
|
+
var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
|
|
31449
|
+
var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
|
|
31450
|
+
function parseBoolEnv(key, opts = {}) {
|
|
31451
|
+
const fallback = opts.default ?? false;
|
|
31452
|
+
const raw = readEnvVar(key, { env: opts.env });
|
|
31453
|
+
if (raw === void 0)
|
|
31454
|
+
return fallback;
|
|
31455
|
+
const token = raw.toLowerCase();
|
|
31456
|
+
if (TRUE_TOKENS.has(token))
|
|
31457
|
+
return true;
|
|
31458
|
+
if (FALSE_TOKENS.has(token))
|
|
31459
|
+
return false;
|
|
31460
|
+
return fallback;
|
|
31461
|
+
}
|
|
31443
31462
|
async function loadDotenvSafely(opts = {}) {
|
|
31444
31463
|
try {
|
|
31445
31464
|
const mod = await import(
|
|
@@ -31457,6 +31476,34 @@ async function loadDotenvSafely(opts = {}) {
|
|
|
31457
31476
|
}
|
|
31458
31477
|
}
|
|
31459
31478
|
|
|
31479
|
+
// node_modules/@chrischall/mcp-utils/dist/http/index.js
|
|
31480
|
+
var RateLimitedError = class extends Error {
|
|
31481
|
+
status = 429;
|
|
31482
|
+
constructor(service) {
|
|
31483
|
+
super(`Rate limited (429) by ${service} after retries.`);
|
|
31484
|
+
this.name = "RateLimitedError";
|
|
31485
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
31486
|
+
}
|
|
31487
|
+
};
|
|
31488
|
+
var RequestTimeoutError = class extends Error {
|
|
31489
|
+
timeoutMs;
|
|
31490
|
+
constructor(service, timeoutMs) {
|
|
31491
|
+
super(`Request to ${service} timed out after ${timeoutMs}ms.`);
|
|
31492
|
+
this.name = "RequestTimeoutError";
|
|
31493
|
+
this.timeoutMs = timeoutMs;
|
|
31494
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
31495
|
+
}
|
|
31496
|
+
};
|
|
31497
|
+
var ApiError = class extends Error {
|
|
31498
|
+
status;
|
|
31499
|
+
constructor(status, message) {
|
|
31500
|
+
super(message);
|
|
31501
|
+
this.name = "ApiError";
|
|
31502
|
+
this.status = status;
|
|
31503
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
31504
|
+
}
|
|
31505
|
+
};
|
|
31506
|
+
|
|
31460
31507
|
// node_modules/@chrischall/mcp-utils/dist/zod/index.js
|
|
31461
31508
|
var PositiveInt = external_exports.number().int().positive();
|
|
31462
31509
|
var NonNegInt = external_exports.number().int().nonnegative();
|
|
@@ -31485,57 +31532,313 @@ function toolAnnotations(opts = {}) {
|
|
|
31485
31532
|
}
|
|
31486
31533
|
|
|
31487
31534
|
// node_modules/@chrischall/mcp-utils/dist/session/index.js
|
|
31535
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
|
|
31536
|
+
import { dirname, join, resolve } from "node:path";
|
|
31537
|
+
import { homedir } from "node:os";
|
|
31538
|
+
import { randomBytes, createHmac } from "node:crypto";
|
|
31539
|
+
function bindingDigest(secret, salt) {
|
|
31540
|
+
return createHmac("sha256", salt).update(secret).digest("hex").slice(0, 32);
|
|
31541
|
+
}
|
|
31542
|
+
function isEnvelope(raw) {
|
|
31543
|
+
return raw !== null && typeof raw === "object" && raw.v === 1 && "state" in raw;
|
|
31544
|
+
}
|
|
31545
|
+
var StatePersistenceError = class extends Error {
|
|
31546
|
+
cause;
|
|
31547
|
+
constructor(cause) {
|
|
31548
|
+
super(cause instanceof Error ? cause.message : String(cause));
|
|
31549
|
+
this.name = "StatePersistenceError";
|
|
31550
|
+
this.cause = cause;
|
|
31551
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
31552
|
+
}
|
|
31553
|
+
};
|
|
31554
|
+
function createFileStatePersistence(opts) {
|
|
31555
|
+
const { filePath, validate } = opts;
|
|
31556
|
+
const secret = opts.boundTo;
|
|
31557
|
+
return {
|
|
31558
|
+
load() {
|
|
31559
|
+
if (!existsSync(filePath))
|
|
31560
|
+
return null;
|
|
31561
|
+
try {
|
|
31562
|
+
const raw = JSON.parse(readFileSync(filePath, "utf8"));
|
|
31563
|
+
let state;
|
|
31564
|
+
if (isEnvelope(raw)) {
|
|
31565
|
+
const b = raw.boundTo;
|
|
31566
|
+
if (secret === void 0) {
|
|
31567
|
+
if (b !== void 0)
|
|
31568
|
+
return null;
|
|
31569
|
+
} else {
|
|
31570
|
+
if (b === void 0)
|
|
31571
|
+
return null;
|
|
31572
|
+
if (bindingDigest(secret, b.salt) !== b.digest)
|
|
31573
|
+
return null;
|
|
31574
|
+
}
|
|
31575
|
+
state = raw.state;
|
|
31576
|
+
} else {
|
|
31577
|
+
if (secret !== void 0)
|
|
31578
|
+
return null;
|
|
31579
|
+
state = raw;
|
|
31580
|
+
}
|
|
31581
|
+
if (validate !== void 0)
|
|
31582
|
+
return validate(state);
|
|
31583
|
+
return state;
|
|
31584
|
+
} catch {
|
|
31585
|
+
return null;
|
|
31586
|
+
}
|
|
31587
|
+
},
|
|
31588
|
+
save(state) {
|
|
31589
|
+
const dir = dirname(filePath);
|
|
31590
|
+
const tmp = `${filePath}.tmp-${randomBytes(6).toString("hex")}`;
|
|
31591
|
+
const dirExisted = existsSync(dir);
|
|
31592
|
+
try {
|
|
31593
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
31594
|
+
if (!dirExisted)
|
|
31595
|
+
chmodSync(dir, 448);
|
|
31596
|
+
let boundTo;
|
|
31597
|
+
if (secret !== void 0) {
|
|
31598
|
+
const salt = randomBytes(16).toString("hex");
|
|
31599
|
+
boundTo = { salt, digest: bindingDigest(secret, salt) };
|
|
31600
|
+
}
|
|
31601
|
+
const envelope = { v: 1, ...boundTo !== void 0 ? { boundTo } : {}, state };
|
|
31602
|
+
writeFileSync(tmp, JSON.stringify(envelope, null, 2), { mode: 384 });
|
|
31603
|
+
chmodSync(tmp, 384);
|
|
31604
|
+
renameSync(tmp, filePath);
|
|
31605
|
+
chmodSync(filePath, 384);
|
|
31606
|
+
} catch (err) {
|
|
31607
|
+
try {
|
|
31608
|
+
if (existsSync(tmp))
|
|
31609
|
+
unlinkSync(tmp);
|
|
31610
|
+
} catch {
|
|
31611
|
+
}
|
|
31612
|
+
throw err;
|
|
31613
|
+
}
|
|
31614
|
+
},
|
|
31615
|
+
clear() {
|
|
31616
|
+
try {
|
|
31617
|
+
if (existsSync(filePath))
|
|
31618
|
+
unlinkSync(filePath);
|
|
31619
|
+
} catch {
|
|
31620
|
+
}
|
|
31621
|
+
}
|
|
31622
|
+
};
|
|
31623
|
+
}
|
|
31624
|
+
function homeOf(env) {
|
|
31625
|
+
return readEnvVar("HOME", { env }) ?? homedir();
|
|
31626
|
+
}
|
|
31627
|
+
function resolveStateDir(opts = {}) {
|
|
31628
|
+
const env = opts.env;
|
|
31629
|
+
return join(readEnvVar("MCP_DATA_DIR", { env }) ?? homeOf(env), opts.subdir ?? "");
|
|
31630
|
+
}
|
|
31631
|
+
function resolveStateFile(opts) {
|
|
31632
|
+
const override = opts.envVar !== void 0 ? readEnvVar(opts.envVar, { env: opts.env }) : void 0;
|
|
31633
|
+
if (override !== void 0) {
|
|
31634
|
+
if (override === "~")
|
|
31635
|
+
return homeOf(opts.env);
|
|
31636
|
+
if (override.startsWith("~/"))
|
|
31637
|
+
return join(homeOf(opts.env), override.slice(2));
|
|
31638
|
+
return resolve(override);
|
|
31639
|
+
}
|
|
31640
|
+
const dirOpts = { env: opts.env };
|
|
31641
|
+
if (opts.subdir !== void 0)
|
|
31642
|
+
dirOpts.subdir = opts.subdir;
|
|
31643
|
+
return join(resolveStateDir(dirOpts), opts.fileName);
|
|
31644
|
+
}
|
|
31488
31645
|
var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
31646
|
+
function defaultIsRefreshRevoked(err) {
|
|
31647
|
+
if (err instanceof RateLimitedError || err instanceof RequestTimeoutError)
|
|
31648
|
+
return false;
|
|
31649
|
+
if (err instanceof ApiError && err.status >= 500)
|
|
31650
|
+
return false;
|
|
31651
|
+
return true;
|
|
31652
|
+
}
|
|
31653
|
+
function isBearerTokens(raw) {
|
|
31654
|
+
if (raw === null || typeof raw !== "object")
|
|
31655
|
+
return false;
|
|
31656
|
+
const t = raw;
|
|
31657
|
+
if (typeof t.accessToken !== "string" || t.accessToken === "")
|
|
31658
|
+
return false;
|
|
31659
|
+
if (typeof t.expiresAt !== "number" || !Number.isFinite(t.expiresAt))
|
|
31660
|
+
return false;
|
|
31661
|
+
return t.refreshToken === void 0 || typeof t.refreshToken === "string";
|
|
31662
|
+
}
|
|
31489
31663
|
var TokenManager = class {
|
|
31490
|
-
|
|
31491
|
-
|
|
31492
|
-
expiresAt;
|
|
31664
|
+
tokens;
|
|
31665
|
+
bootstrapFn;
|
|
31493
31666
|
refreshFn;
|
|
31494
31667
|
skewMs;
|
|
31668
|
+
persistence;
|
|
31669
|
+
now;
|
|
31670
|
+
isRefreshRevokedFn;
|
|
31671
|
+
onPersistErrorFn;
|
|
31495
31672
|
inFlight;
|
|
31673
|
+
bootstrapInFlight;
|
|
31674
|
+
/**
|
|
31675
|
+
* Persistence is consulted at most once per process. Without this the
|
|
31676
|
+
* revoked-token recovery below re-reads the SAME rejected record — `clear()`
|
|
31677
|
+
* is optional on {@link StatePersistence} and its failures are swallowed, so
|
|
31678
|
+
* recovery must not depend on it. After the first read the in-memory tokens
|
|
31679
|
+
* (or their deliberate absence) are the truth.
|
|
31680
|
+
*/
|
|
31681
|
+
persistenceRead = false;
|
|
31496
31682
|
constructor(opts) {
|
|
31497
|
-
|
|
31498
|
-
|
|
31499
|
-
|
|
31683
|
+
if (typeof opts.initial === "function") {
|
|
31684
|
+
this.bootstrapFn = opts.initial;
|
|
31685
|
+
} else {
|
|
31686
|
+
this.tokens = { ...opts.initial };
|
|
31687
|
+
}
|
|
31500
31688
|
this.refreshFn = opts.refresh;
|
|
31501
31689
|
this.skewMs = opts.skewMs ?? TOKEN_REFRESH_SKEW_MS;
|
|
31690
|
+
this.persistence = opts.persistence;
|
|
31691
|
+
this.now = opts.now ?? Date.now;
|
|
31692
|
+
this.isRefreshRevokedFn = opts.isRefreshRevoked ?? defaultIsRefreshRevoked;
|
|
31693
|
+
this.onPersistErrorFn = opts.onPersistError;
|
|
31502
31694
|
}
|
|
31503
31695
|
/** Whether the token is within the skew window of (or past) expiry. */
|
|
31504
31696
|
needsRefresh() {
|
|
31505
|
-
|
|
31697
|
+
if (this.tokens === void 0)
|
|
31698
|
+
return false;
|
|
31699
|
+
return this.now() >= this.tokens.expiresAt - this.skewMs;
|
|
31700
|
+
}
|
|
31701
|
+
/**
|
|
31702
|
+
* A stored token is worth using when it is still valid, OR when it carries a
|
|
31703
|
+
* refresh token — an expired-but-refreshable token still saves the login,
|
|
31704
|
+
* which is the expensive half.
|
|
31705
|
+
*/
|
|
31706
|
+
isUsable(t) {
|
|
31707
|
+
return this.now() < t.expiresAt - this.skewMs || t.refreshToken !== void 0;
|
|
31708
|
+
}
|
|
31709
|
+
/** Read persisted tokens, guarding shape and usability. Never throws. */
|
|
31710
|
+
async loadPersisted() {
|
|
31711
|
+
if (this.persistence === void 0 || this.persistenceRead)
|
|
31712
|
+
return null;
|
|
31713
|
+
this.persistenceRead = true;
|
|
31714
|
+
try {
|
|
31715
|
+
const raw = await this.persistence.load();
|
|
31716
|
+
if (!isBearerTokens(raw) || !this.isUsable(raw))
|
|
31717
|
+
return null;
|
|
31718
|
+
return raw;
|
|
31719
|
+
} catch {
|
|
31720
|
+
return null;
|
|
31721
|
+
}
|
|
31722
|
+
}
|
|
31723
|
+
/**
|
|
31724
|
+
* Write tokens. Silent by default (a lost write costs a future login, not this
|
|
31725
|
+
* request); throws a {@link StatePersistenceError} when `onPersistError` does.
|
|
31726
|
+
*/
|
|
31727
|
+
async persist(t) {
|
|
31728
|
+
if (this.persistence === void 0)
|
|
31729
|
+
return;
|
|
31730
|
+
try {
|
|
31731
|
+
await this.persistence.save(t);
|
|
31732
|
+
} catch (err) {
|
|
31733
|
+
if (this.onPersistErrorFn === void 0)
|
|
31734
|
+
return;
|
|
31735
|
+
try {
|
|
31736
|
+
this.onPersistErrorFn(err);
|
|
31737
|
+
} catch (hookErr) {
|
|
31738
|
+
throw new StatePersistenceError(hookErr);
|
|
31739
|
+
}
|
|
31740
|
+
}
|
|
31741
|
+
}
|
|
31742
|
+
/** Discard persisted tokens (a refresh they could not satisfy). Never throws. */
|
|
31743
|
+
async clearPersisted() {
|
|
31744
|
+
if (this.persistence?.clear === void 0)
|
|
31745
|
+
return;
|
|
31746
|
+
try {
|
|
31747
|
+
await this.persistence.clear();
|
|
31748
|
+
} catch {
|
|
31749
|
+
}
|
|
31750
|
+
}
|
|
31751
|
+
/** The current tokens, single-flighting the bootstrap if there are none. */
|
|
31752
|
+
ensureTokens() {
|
|
31753
|
+
if (this.tokens !== void 0)
|
|
31754
|
+
return Promise.resolve(this.tokens);
|
|
31755
|
+
if (this.bootstrapInFlight === void 0) {
|
|
31756
|
+
this.bootstrapInFlight = this.runBootstrap().finally(() => {
|
|
31757
|
+
this.bootstrapInFlight = void 0;
|
|
31758
|
+
});
|
|
31759
|
+
}
|
|
31760
|
+
return this.bootstrapInFlight;
|
|
31761
|
+
}
|
|
31762
|
+
/** One bootstrap attempt: persisted tokens if usable, else the login. */
|
|
31763
|
+
async runBootstrap() {
|
|
31764
|
+
const stored = await this.loadPersisted();
|
|
31765
|
+
if (stored !== null) {
|
|
31766
|
+
this.tokens = stored;
|
|
31767
|
+
return stored;
|
|
31768
|
+
}
|
|
31769
|
+
if (this.bootstrapFn === void 0) {
|
|
31770
|
+
throw new Error("TokenManager: no tokens and no bootstrap function to mint them.");
|
|
31771
|
+
}
|
|
31772
|
+
const fresh = await this.bootstrapFn();
|
|
31773
|
+
this.tokens = fresh;
|
|
31774
|
+
try {
|
|
31775
|
+
await this.persist(fresh);
|
|
31776
|
+
} catch (err) {
|
|
31777
|
+
throw err instanceof StatePersistenceError ? err.cause : err;
|
|
31778
|
+
}
|
|
31779
|
+
return fresh;
|
|
31506
31780
|
}
|
|
31507
31781
|
/**
|
|
31508
31782
|
* Single-flight refresh. Concurrent callers share one in-flight promise; it is
|
|
31509
31783
|
* cleared on settle (success or failure) so a subsequent refresh can proceed.
|
|
31510
31784
|
*/
|
|
31511
31785
|
refreshNow() {
|
|
31512
|
-
if (
|
|
31513
|
-
|
|
31514
|
-
if (rt === void 0) {
|
|
31515
|
-
return Promise.reject(new Error("TokenManager: cannot refresh \u2014 no refresh token is available."));
|
|
31516
|
-
}
|
|
31517
|
-
this.inFlight = (async () => {
|
|
31518
|
-
const tok = await this.refreshFn(rt);
|
|
31519
|
-
this.accessToken = tok.accessToken;
|
|
31520
|
-
if (tok.refreshToken !== void 0 && tok.refreshToken !== "") {
|
|
31521
|
-
this.refreshToken = tok.refreshToken;
|
|
31522
|
-
}
|
|
31523
|
-
this.expiresAt = tok.expiresAt;
|
|
31524
|
-
})().finally(() => {
|
|
31786
|
+
if (this.inFlight === void 0) {
|
|
31787
|
+
this.inFlight = this.runRefresh().finally(() => {
|
|
31525
31788
|
this.inFlight = void 0;
|
|
31526
31789
|
});
|
|
31527
31790
|
}
|
|
31528
31791
|
return this.inFlight;
|
|
31529
31792
|
}
|
|
31793
|
+
/** One refresh attempt against the current refresh token. */
|
|
31794
|
+
async runRefresh() {
|
|
31795
|
+
const current = this.tokens ?? await this.ensureTokens();
|
|
31796
|
+
const rt = current.refreshToken;
|
|
31797
|
+
if (rt === void 0) {
|
|
31798
|
+
throw new Error("TokenManager: cannot refresh \u2014 no refresh token is available.");
|
|
31799
|
+
}
|
|
31800
|
+
const tok = await this.refreshFn(rt);
|
|
31801
|
+
this.tokens = {
|
|
31802
|
+
accessToken: tok.accessToken,
|
|
31803
|
+
// Rotation is optional: keep the current refresh token when none comes back.
|
|
31804
|
+
refreshToken: tok.refreshToken !== void 0 && tok.refreshToken !== "" ? tok.refreshToken : rt,
|
|
31805
|
+
expiresAt: tok.expiresAt
|
|
31806
|
+
};
|
|
31807
|
+
await this.persist(this.tokens);
|
|
31808
|
+
}
|
|
31809
|
+
/**
|
|
31810
|
+
* Recover from a refresh the current credential could not satisfy — commonly
|
|
31811
|
+
* a refresh token restored from a previous process and revoked since. Without
|
|
31812
|
+
* a bootstrap to fall back on this is terminal; with one, re-minting beats
|
|
31813
|
+
* staying broken forever. Shared so the two entry points cannot diverge.
|
|
31814
|
+
*/
|
|
31815
|
+
async reBootstrap(err) {
|
|
31816
|
+
if (err instanceof StatePersistenceError)
|
|
31817
|
+
throw err.cause;
|
|
31818
|
+
if (this.bootstrapFn === void 0)
|
|
31819
|
+
throw err;
|
|
31820
|
+
if (!this.isRefreshRevokedFn(err))
|
|
31821
|
+
throw err;
|
|
31822
|
+
this.tokens = void 0;
|
|
31823
|
+
await this.clearPersisted();
|
|
31824
|
+
return this.ensureTokens();
|
|
31825
|
+
}
|
|
31530
31826
|
/** Get a valid access token, refreshing proactively inside the skew window. */
|
|
31531
31827
|
async getAccessToken() {
|
|
31532
|
-
|
|
31533
|
-
|
|
31534
|
-
|
|
31828
|
+
let tokens = this.tokens ?? await this.ensureTokens();
|
|
31829
|
+
if (this.needsRefresh()) {
|
|
31830
|
+
try {
|
|
31831
|
+
await this.refreshNow();
|
|
31832
|
+
} catch (err) {
|
|
31833
|
+
return (await this.reBootstrap(err)).accessToken;
|
|
31834
|
+
}
|
|
31835
|
+
tokens = this.tokens ?? tokens;
|
|
31836
|
+
}
|
|
31837
|
+
return tokens.accessToken;
|
|
31535
31838
|
}
|
|
31536
|
-
/** Current absolute expiry (epoch ms). */
|
|
31839
|
+
/** Current absolute expiry (epoch ms), or `0` before the first bootstrap. */
|
|
31537
31840
|
getExpiresAt() {
|
|
31538
|
-
return this.expiresAt;
|
|
31841
|
+
return this.tokens?.expiresAt ?? 0;
|
|
31539
31842
|
}
|
|
31540
31843
|
/**
|
|
31541
31844
|
* Run an authenticated request with reactive 401-replay. `call` receives a
|
|
@@ -31553,9 +31856,14 @@ var TokenManager = class {
|
|
|
31553
31856
|
const usedToken = await this.getAccessToken();
|
|
31554
31857
|
let res = await call(usedToken);
|
|
31555
31858
|
if (res.status === 401) {
|
|
31556
|
-
if (this.accessToken === usedToken)
|
|
31557
|
-
|
|
31558
|
-
|
|
31859
|
+
if (this.tokens?.accessToken === usedToken) {
|
|
31860
|
+
try {
|
|
31861
|
+
await this.refreshNow();
|
|
31862
|
+
} catch (err) {
|
|
31863
|
+
await this.reBootstrap(err);
|
|
31864
|
+
}
|
|
31865
|
+
}
|
|
31866
|
+
res = await call(this.tokens?.accessToken ?? usedToken);
|
|
31559
31867
|
}
|
|
31560
31868
|
return res;
|
|
31561
31869
|
}
|
|
@@ -31574,6 +31882,41 @@ function loadConfig(env = process.env) {
|
|
|
31574
31882
|
};
|
|
31575
31883
|
}
|
|
31576
31884
|
|
|
31885
|
+
// src/token-cache.ts
|
|
31886
|
+
function tokenCachePath(env = process.env) {
|
|
31887
|
+
return resolveStateFile({
|
|
31888
|
+
env,
|
|
31889
|
+
envVar: "MYHOTLUNCHBOX_TOKEN_FILE",
|
|
31890
|
+
subdir: ".myhotlunchbox-mcp",
|
|
31891
|
+
fileName: "token.json"
|
|
31892
|
+
});
|
|
31893
|
+
}
|
|
31894
|
+
function isTokens(raw) {
|
|
31895
|
+
if (raw === null || typeof raw !== "object") return false;
|
|
31896
|
+
const t = raw;
|
|
31897
|
+
return typeof t.accessToken === "string" && t.accessToken !== "" && typeof t.expiresAt === "number" && (t.refreshToken === void 0 || typeof t.refreshToken === "string");
|
|
31898
|
+
}
|
|
31899
|
+
function createTokenCache(env = process.env) {
|
|
31900
|
+
if (!parseBoolEnv("MYHOTLUNCHBOX_TOKEN_CACHE", { env, default: true })) return null;
|
|
31901
|
+
const username = readEnvVar("MYHOTLUNCHBOX_USERNAME", { env });
|
|
31902
|
+
const password = readEnvVar("MYHOTLUNCHBOX_PASSWORD", { env });
|
|
31903
|
+
if (username === void 0 || password === void 0) return null;
|
|
31904
|
+
return createFileStatePersistence({
|
|
31905
|
+
filePath: tokenCachePath(env),
|
|
31906
|
+
// Joined on a NUL, written as an escape rather than a literal byte: a
|
|
31907
|
+
// password may contain spaces, so a space-joined pair could collide with a
|
|
31908
|
+
// different pair by shifting the boundary between the two halves.
|
|
31909
|
+
boundTo: [username.trim().toLowerCase(), password].join("\0"),
|
|
31910
|
+
validate: (raw) => isTokens(raw) ? raw : null
|
|
31911
|
+
});
|
|
31912
|
+
}
|
|
31913
|
+
function reportCacheWriteFailure(err) {
|
|
31914
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
31915
|
+
console.error(
|
|
31916
|
+
`[myhotlunchbox-mcp] could not cache the OAuth tokens (${detail}); continuing without the cache \u2014 every restart will log in again until this is fixed.`
|
|
31917
|
+
);
|
|
31918
|
+
}
|
|
31919
|
+
|
|
31577
31920
|
// src/auth.ts
|
|
31578
31921
|
var DEFAULT_TOKEN_LIFETIME_S = 3600;
|
|
31579
31922
|
function scrubCredentials(text, secrets) {
|
|
@@ -31681,13 +32024,24 @@ var MhlbAuth = class _MhlbAuth {
|
|
|
31681
32024
|
if (this.manager) return this.manager;
|
|
31682
32025
|
if (this.loginInFlight) return this.loginInFlight;
|
|
31683
32026
|
this.loginInFlight = (async () => {
|
|
31684
|
-
const body = await this.passwordLogin();
|
|
31685
32027
|
const manager = new TokenManager({
|
|
31686
|
-
|
|
31687
|
-
|
|
31688
|
-
|
|
31689
|
-
|
|
32028
|
+
// The FUNCTION form, so the cache is consulted before the password
|
|
32029
|
+
// grant runs at all — the eager object form skips persistence, which
|
|
32030
|
+
// would mean a cache that is written and never read.
|
|
32031
|
+
initial: async () => {
|
|
32032
|
+
const body = await this.passwordLogin();
|
|
32033
|
+
return {
|
|
32034
|
+
accessToken: this.assertAccessToken(body),
|
|
32035
|
+
refreshToken: body.refresh_token,
|
|
32036
|
+
expiresAt: _MhlbAuth.expiryOf(body)
|
|
32037
|
+
};
|
|
31690
32038
|
},
|
|
32039
|
+
persistence: createTokenCache() ?? void 0,
|
|
32040
|
+
onPersistError: reportCacheWriteFailure,
|
|
32041
|
+
// `refresh` below already recovers from a revoked refresh token by
|
|
32042
|
+
// falling back to a full login, so the library's own re-mint-on-revoked
|
|
32043
|
+
// recovery would only add a third attempt after both have failed.
|
|
32044
|
+
isRefreshRevoked: () => false,
|
|
31691
32045
|
refresh: async (refreshToken) => {
|
|
31692
32046
|
let next;
|
|
31693
32047
|
try {
|
|
@@ -31891,7 +32245,7 @@ var MhlbClient = class {
|
|
|
31891
32245
|
};
|
|
31892
32246
|
|
|
31893
32247
|
// src/version.ts
|
|
31894
|
-
var VERSION = "0.
|
|
32248
|
+
var VERSION = "0.4.0";
|
|
31895
32249
|
|
|
31896
32250
|
// src/tools/_shared.ts
|
|
31897
32251
|
function preview(action, request, notes) {
|
|
@@ -32420,12 +32774,12 @@ function checkoutBody(args) {
|
|
|
32420
32774
|
}
|
|
32421
32775
|
|
|
32422
32776
|
// src/tools/reports.ts
|
|
32423
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
32424
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
32777
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
32778
|
+
import { isAbsolute, join as join2, resolve as resolve2 } from "node:path";
|
|
32425
32779
|
var MAX_INLINE_BYTES = 75e4;
|
|
32426
32780
|
function outputDir(env = process.env) {
|
|
32427
32781
|
const configured = readEnvVar("MYHOTLUNCHBOX_OUTPUT_DIR", { env });
|
|
32428
|
-
return configured ?
|
|
32782
|
+
return configured ? resolve2(configured) : process.cwd();
|
|
32429
32783
|
}
|
|
32430
32784
|
function* candidateNames(filename) {
|
|
32431
32785
|
const dot = filename.lastIndexOf(".");
|
|
@@ -32440,9 +32794,9 @@ function writeWithoutClobbering(dir, filename, bytes) {
|
|
|
32440
32794
|
for (const name of candidateNames(filename)) {
|
|
32441
32795
|
if (attempts >= MAX_NAME_ATTEMPTS) break;
|
|
32442
32796
|
attempts += 1;
|
|
32443
|
-
const candidate =
|
|
32797
|
+
const candidate = join2(dir, name);
|
|
32444
32798
|
try {
|
|
32445
|
-
|
|
32799
|
+
writeFileSync2(candidate, bytes, { flag: "wx" });
|
|
32446
32800
|
return candidate;
|
|
32447
32801
|
} catch (err) {
|
|
32448
32802
|
if (err.code !== "EEXIST") throw err;
|
|
@@ -32484,7 +32838,7 @@ function deliver(bytes, contentType, filename, inline) {
|
|
|
32484
32838
|
const dir = outputDir();
|
|
32485
32839
|
let path;
|
|
32486
32840
|
try {
|
|
32487
|
-
|
|
32841
|
+
mkdirSync2(dir, { recursive: true });
|
|
32488
32842
|
path = writeWithoutClobbering(dir, filename, bytes);
|
|
32489
32843
|
} catch (cause) {
|
|
32490
32844
|
if (cause instanceof McpToolError) throw cause;
|
|
@@ -32570,6 +32924,181 @@ function registerReportTools(server, client2) {
|
|
|
32570
32924
|
);
|
|
32571
32925
|
}
|
|
32572
32926
|
|
|
32927
|
+
// node_modules/@chrischall/mcp-utils/dist/healthcheck/index.js
|
|
32928
|
+
function statusOf(err) {
|
|
32929
|
+
if (typeof err !== "object" || err === null)
|
|
32930
|
+
return void 0;
|
|
32931
|
+
const s = err.status ?? err.statusCode;
|
|
32932
|
+
return typeof s === "number" ? s : void 0;
|
|
32933
|
+
}
|
|
32934
|
+
var CREDENTIAL_ARMS = /* @__PURE__ */ new Set([
|
|
32935
|
+
"ok",
|
|
32936
|
+
"no_credential",
|
|
32937
|
+
"credential_rejected",
|
|
32938
|
+
"timeout",
|
|
32939
|
+
"http",
|
|
32940
|
+
"transport",
|
|
32941
|
+
"unknown"
|
|
32942
|
+
]);
|
|
32943
|
+
function isArm(kind) {
|
|
32944
|
+
return kind !== void 0 && CREDENTIAL_ARMS.has(kind);
|
|
32945
|
+
}
|
|
32946
|
+
function credentialHint(arm, prefix, hostLabel, source) {
|
|
32947
|
+
switch (arm) {
|
|
32948
|
+
case "ok":
|
|
32949
|
+
return `Credential from '${source}' works: ${hostLabel} accepted an authenticated request. If a real tool still fails, the problem is that tool, not auth.`;
|
|
32950
|
+
case "no_credential":
|
|
32951
|
+
return `No credential resolved. Nothing was available to authenticate with \u2014 sign in and reconnect the connector so ${prefix} receives a token, or set the documented environment variable.`;
|
|
32952
|
+
case "credential_rejected":
|
|
32953
|
+
return `${hostLabel} rejected the credential from '${source}'. It is present but no longer valid \u2014 most often expired or revoked upstream. Re-authenticate and reconnect; retrying will not fix it.`;
|
|
32954
|
+
case "timeout":
|
|
32955
|
+
return `The credential from '${source}' resolved, but ${hostLabel} did not answer in time. Usually transient \u2014 retry. If it persists, ${hostLabel} is slow or unreachable from here.`;
|
|
32956
|
+
case "http":
|
|
32957
|
+
return `${hostLabel} answered with an error status that is not an auth rejection. That is USUALLY a ${hostLabel}-side problem rather than an auth one \u2014 but a 404 here more often means the probe path is wrong than that ${hostLabel} is broken, so check error.message and probe.url before concluding anything about the credential.`;
|
|
32958
|
+
case "transport":
|
|
32959
|
+
return `Could not reach ${hostLabel} at all. Check network egress; the credential itself was never judged.`;
|
|
32960
|
+
default:
|
|
32961
|
+
return `Unexpected failure \u2014 see error.message.`;
|
|
32962
|
+
}
|
|
32963
|
+
}
|
|
32964
|
+
function registerCredentialHealthcheckTool(args) {
|
|
32965
|
+
const { server, prefix, hostLabel, probePath, resolveCredential, probeFn, classifyThrown, hints } = args;
|
|
32966
|
+
const probeUrl = probePath ? `https://${hostLabel}${probePath}` : void 0;
|
|
32967
|
+
server.registerTool(`${prefix}_healthcheck`, {
|
|
32968
|
+
title: "Verify credentials and upstream reachability",
|
|
32969
|
+
description: `Resolves the credential the way real tools do, then makes one authenticated request to ${hostLabel}. Reports which source supplied the credential, whether ${hostLabel} accepted it, the round-trip time, and a plain-English hint distinguishing 'no credential' from 'credential rejected' from 'a ${hostLabel}-side problem'. Call this when a real tool fails and you want to know which hop broke. Read-only; never returns the credential itself.`,
|
|
32970
|
+
annotations: {
|
|
32971
|
+
title: "Verify credentials and upstream reachability",
|
|
32972
|
+
readOnlyHint: true,
|
|
32973
|
+
idempotentHint: true,
|
|
32974
|
+
openWorldHint: true
|
|
32975
|
+
},
|
|
32976
|
+
inputSchema: {}
|
|
32977
|
+
}, async () => {
|
|
32978
|
+
let probeStarted = 0;
|
|
32979
|
+
let state;
|
|
32980
|
+
try {
|
|
32981
|
+
state = await resolveCredential();
|
|
32982
|
+
} catch (e) {
|
|
32983
|
+
const classified = classifyThrown?.(e);
|
|
32984
|
+
const result2 = {
|
|
32985
|
+
ok: false,
|
|
32986
|
+
// Still false, and still no source: a classification explains WHY
|
|
32987
|
+
// nothing resolved, it does not invent a credential that did.
|
|
32988
|
+
credential: { source: null, resolved: false },
|
|
32989
|
+
// No `url`: nothing was probed, and naming one implies it was tried.
|
|
32990
|
+
probe: { elapsed_ms: 0 },
|
|
32991
|
+
error: {
|
|
32992
|
+
kind: classified?.kind ?? "no_credential",
|
|
32993
|
+
message: truncateErrorMessage(messageOf(e)),
|
|
32994
|
+
...classified?.detail !== void 0 ? { detail: classified.detail } : {}
|
|
32995
|
+
},
|
|
32996
|
+
// The hint must follow the KIND beside it. Falling back to
|
|
32997
|
+
// `no_credential`'s copy under a classified kind would state a cause
|
|
32998
|
+
// the kind contradicts — the same disagreement this path exists to
|
|
32999
|
+
// remove. So: an inline hint wins; else the classified arm's own
|
|
33000
|
+
// copy (consumer override first); else, for a kind this module has
|
|
33001
|
+
// no copy for, the neutral `unknown` text rather than one that
|
|
33002
|
+
// asserts a cause; else the unclassified `no_credential` default.
|
|
33003
|
+
hint: classified?.hint ?? (isArm(classified?.kind) ? hints?.[classified.kind] ?? credentialHint(classified.kind, prefix, hostLabel, null) : classified !== void 0 ? credentialHint("unknown", prefix, hostLabel, null) : hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null))
|
|
33004
|
+
};
|
|
33005
|
+
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
33006
|
+
}
|
|
33007
|
+
const credential = {
|
|
33008
|
+
source: state.source,
|
|
33009
|
+
resolved: state.source !== null,
|
|
33010
|
+
...state.detail !== void 0 ? { detail: state.detail } : {}
|
|
33011
|
+
};
|
|
33012
|
+
if (!credential.resolved) {
|
|
33013
|
+
const result2 = {
|
|
33014
|
+
ok: false,
|
|
33015
|
+
credential,
|
|
33016
|
+
probe: { elapsed_ms: 0 },
|
|
33017
|
+
error: { kind: "no_credential", message: "no credential source resolved" },
|
|
33018
|
+
hint: hints?.no_credential ?? credentialHint("no_credential", prefix, hostLabel, null)
|
|
33019
|
+
};
|
|
33020
|
+
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
33021
|
+
}
|
|
33022
|
+
let arm = "ok";
|
|
33023
|
+
let error51;
|
|
33024
|
+
let status;
|
|
33025
|
+
let customHint;
|
|
33026
|
+
probeStarted = Date.now();
|
|
33027
|
+
try {
|
|
33028
|
+
await probeFn();
|
|
33029
|
+
} catch (e) {
|
|
33030
|
+
status = statusOf(e);
|
|
33031
|
+
const aborted2 = e instanceof Error && e.name === "AbortError";
|
|
33032
|
+
arm = status === 401 || status === 403 ? "credential_rejected" : status !== void 0 ? "http" : aborted2 || /timeout|timed out|ETIMEDOUT/i.test(messageOf(e)) ? "timeout" : /fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|network/i.test(messageOf(e)) ? "transport" : "unknown";
|
|
33033
|
+
let kind = arm;
|
|
33034
|
+
let detail;
|
|
33035
|
+
const custom2 = classifyThrown?.(e);
|
|
33036
|
+
if (custom2) {
|
|
33037
|
+
kind = custom2.kind;
|
|
33038
|
+
customHint = custom2.hint;
|
|
33039
|
+
detail = custom2.detail;
|
|
33040
|
+
}
|
|
33041
|
+
error51 = {
|
|
33042
|
+
kind,
|
|
33043
|
+
// Redacted AND bounded before it reaches the result: an upstream
|
|
33044
|
+
// failure routinely quotes what it was sent, and a healthcheck is
|
|
33045
|
+
// the tool people paste into a chat when something is broken.
|
|
33046
|
+
message: truncateErrorMessage(messageOf(e)),
|
|
33047
|
+
...detail !== void 0 ? { detail } : {}
|
|
33048
|
+
};
|
|
33049
|
+
}
|
|
33050
|
+
const result = {
|
|
33051
|
+
ok: error51 === void 0,
|
|
33052
|
+
credential,
|
|
33053
|
+
probe: {
|
|
33054
|
+
...probeUrl ? { url: probeUrl } : {},
|
|
33055
|
+
elapsed_ms: Date.now() - probeStarted,
|
|
33056
|
+
...status !== void 0 ? { status } : {}
|
|
33057
|
+
},
|
|
33058
|
+
...error51 ? { error: error51 } : {},
|
|
33059
|
+
hint: customHint ?? hints?.[arm] ?? credentialHint(arm, prefix, hostLabel, state.source)
|
|
33060
|
+
};
|
|
33061
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
33062
|
+
});
|
|
33063
|
+
}
|
|
33064
|
+
|
|
33065
|
+
// src/tools/health.ts
|
|
33066
|
+
var NOT_CONFIGURED = "credentials are not configured";
|
|
33067
|
+
function classifyMhlbError(err) {
|
|
33068
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
33069
|
+
if (msg.includes(NOT_CONFIGURED)) return { kind: "no_credential" };
|
|
33070
|
+
if (msg.includes("Could not reach My Hot Lunchbox")) {
|
|
33071
|
+
return {
|
|
33072
|
+
kind: "unreachable",
|
|
33073
|
+
hint: "Could not reach My Hot Lunchbox at all, so the credential was never tested. Check network connectivity, or MYHOTLUNCHBOX_BASE_URL if the app has moved."
|
|
33074
|
+
};
|
|
33075
|
+
}
|
|
33076
|
+
if (msg.includes("rejected the sign-in")) {
|
|
33077
|
+
return {
|
|
33078
|
+
kind: "credential_rejected",
|
|
33079
|
+
hint: "My Hot Lunchbox rejected the sign-in. Check MYHOTLUNCHBOX_USERNAME / MYHOTLUNCHBOX_PASSWORD against https://ordernow.myhotlunchbox.com \u2014 but do NOT re-run this with guesses: repeated failures can lock the account or force a CAPTCHA that blocks server-side sign-in entirely."
|
|
33080
|
+
};
|
|
33081
|
+
}
|
|
33082
|
+
return void 0;
|
|
33083
|
+
}
|
|
33084
|
+
function registerHealthcheckTools(server, client2, readConfig = () => loadConfig()) {
|
|
33085
|
+
registerCredentialHealthcheckTool({
|
|
33086
|
+
server,
|
|
33087
|
+
prefix: "mhlb",
|
|
33088
|
+
hostLabel: "ordernow.myhotlunchbox.com",
|
|
33089
|
+
probePath: "/api/auth/userinfo",
|
|
33090
|
+
resolveCredential: async () => {
|
|
33091
|
+
const { username, password, baseUrl } = readConfig();
|
|
33092
|
+
const source = username && password ? "MYHOTLUNCHBOX_USERNAME+MYHOTLUNCHBOX_PASSWORD" : null;
|
|
33093
|
+
return { source, detail: { base_url: baseUrl } };
|
|
33094
|
+
},
|
|
33095
|
+
// The same authenticated read `mhlb_whoami` makes: cheap, and it changes
|
|
33096
|
+
// nothing — no order is placed, no balance moved.
|
|
33097
|
+
probeFn: () => client2.get("/auth/userinfo"),
|
|
33098
|
+
classifyThrown: classifyMhlbError
|
|
33099
|
+
});
|
|
33100
|
+
}
|
|
33101
|
+
|
|
32573
33102
|
// src/index.ts
|
|
32574
33103
|
await loadDotenvSafely();
|
|
32575
33104
|
var client = new MhlbClient();
|
|
@@ -32585,6 +33114,7 @@ await runMcp({
|
|
|
32585
33114
|
registerOrderTools,
|
|
32586
33115
|
registerBillingTools,
|
|
32587
33116
|
registerCheckoutTools,
|
|
32588
|
-
registerReportTools
|
|
33117
|
+
registerReportTools,
|
|
33118
|
+
registerHealthcheckTools
|
|
32589
33119
|
]
|
|
32590
33120
|
});
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { registerOrderTools } from './tools/orders.js';
|
|
|
9
9
|
import { registerBillingTools } from './tools/billing.js';
|
|
10
10
|
import { registerCheckoutTools } from './tools/checkout.js';
|
|
11
11
|
import { registerReportTools } from './tools/reports.js';
|
|
12
|
+
import { registerHealthcheckTools } from './tools/health.js';
|
|
12
13
|
await loadDotenvSafely();
|
|
13
14
|
// Built here, in the caller, so the deferred-config-error pattern holds: the
|
|
14
15
|
// server still boots (and answers the host's install-time tools/list probe)
|
|
@@ -27,5 +28,6 @@ await runMcp({
|
|
|
27
28
|
registerBillingTools,
|
|
28
29
|
registerCheckoutTools,
|
|
29
30
|
registerReportTools,
|
|
31
|
+
registerHealthcheckTools,
|
|
30
32
|
],
|
|
31
33
|
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createFileStatePersistence, resolveStateFile, } from '@chrischall/mcp-utils/session';
|
|
2
|
+
import { readEnvVar, parseBoolEnv } from '@chrischall/mcp-utils';
|
|
3
|
+
/** Where the OAuth token pair is cached between runs. */
|
|
4
|
+
export function tokenCachePath(env = process.env) {
|
|
5
|
+
return resolveStateFile({
|
|
6
|
+
env,
|
|
7
|
+
envVar: 'MYHOTLUNCHBOX_TOKEN_FILE',
|
|
8
|
+
subdir: '.myhotlunchbox-mcp',
|
|
9
|
+
fileName: 'token.json',
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/** Only a token pair is ever stored — never the username or password. */
|
|
13
|
+
function isTokens(raw) {
|
|
14
|
+
if (raw === null || typeof raw !== 'object')
|
|
15
|
+
return false;
|
|
16
|
+
const t = raw;
|
|
17
|
+
return (typeof t.accessToken === 'string' &&
|
|
18
|
+
t.accessToken !== '' &&
|
|
19
|
+
typeof t.expiresAt === 'number' &&
|
|
20
|
+
(t.refreshToken === undefined || typeof t.refreshToken === 'string'));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The token cache, or `null` when it is off or there are no credentials to
|
|
24
|
+
* bind a record to.
|
|
25
|
+
*
|
|
26
|
+
* A restart currently spends a full password grant for a token that is usually
|
|
27
|
+
* still valid — and, when it is not, a refresh would have done. Caching turns
|
|
28
|
+
* the first case into nothing and the second into one refresh, which matters on
|
|
29
|
+
* a host where a child idles out after ten minutes and every start is cold.
|
|
30
|
+
*
|
|
31
|
+
* The record is bound to the credentials that minted it, so rotating either
|
|
32
|
+
* discards it rather than leaving a token from the old password in play. Only a
|
|
33
|
+
* salted digest is written; neither value reaches the file.
|
|
34
|
+
*/
|
|
35
|
+
export function createTokenCache(env = process.env) {
|
|
36
|
+
if (!parseBoolEnv('MYHOTLUNCHBOX_TOKEN_CACHE', { env, default: true }))
|
|
37
|
+
return null;
|
|
38
|
+
const username = readEnvVar('MYHOTLUNCHBOX_USERNAME', { env });
|
|
39
|
+
const password = readEnvVar('MYHOTLUNCHBOX_PASSWORD', { env });
|
|
40
|
+
if (username === undefined || password === undefined)
|
|
41
|
+
return null;
|
|
42
|
+
return createFileStatePersistence({
|
|
43
|
+
filePath: tokenCachePath(env),
|
|
44
|
+
// Joined on a NUL, written as an escape rather than a literal byte: a
|
|
45
|
+
// password may contain spaces, so a space-joined pair could collide with a
|
|
46
|
+
// different pair by shifting the boundary between the two halves.
|
|
47
|
+
boundTo: [username.trim().toLowerCase(), password].join('\u0000'),
|
|
48
|
+
validate: (raw) => (isTokens(raw) ? raw : null),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Report a cache write that failed. Not fatal: the tokens are re-mintable from
|
|
53
|
+
* the credentials in the environment, so a lost write costs the next start a
|
|
54
|
+
* login rather than access. Worth saying, though — a read-only data dir
|
|
55
|
+
* otherwise looks exactly like a server that never caches.
|
|
56
|
+
*
|
|
57
|
+
* stderr only; stdout is the JSON-RPC channel.
|
|
58
|
+
*/
|
|
59
|
+
export function reportCacheWriteFailure(err) {
|
|
60
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
61
|
+
console.error(`[myhotlunchbox-mcp] could not cache the OAuth tokens (${detail}); continuing ` +
|
|
62
|
+
'without the cache — every restart will log in again until this is fixed.');
|
|
63
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
/**
|
|
4
|
+
* `mhlb_healthcheck` — the one call that answers "is this connector
|
|
5
|
+
* working?", and the only tool here that reports a failure as DATA rather
|
|
6
|
+
* than throwing.
|
|
7
|
+
*
|
|
8
|
+
* My Hot Lunchbox had none. `mhlb_whoami` looks like one — its own
|
|
9
|
+
* description says "Start here to confirm the session works" — and is not: it
|
|
10
|
+
* is a plain data read that THROWS when auth fails, so the caller gets an
|
|
11
|
+
* exception, not an answer, and cannot tell a bad password from an
|
|
12
|
+
* unreachable host from a lockout.
|
|
13
|
+
*
|
|
14
|
+
* The lockout is why this matters more here than elsewhere. This account
|
|
15
|
+
* counts failed sign-ins and will lock or force a CAPTCHA that blocks
|
|
16
|
+
* server-side sign-in entirely, so a `credential_rejected` hint that invites
|
|
17
|
+
* retrying is actively harmful. {@link classifyMhlbError} carries that
|
|
18
|
+
* warning instead.
|
|
19
|
+
*/
|
|
20
|
+
const NOT_CONFIGURED = 'credentials are not configured';
|
|
21
|
+
export function classifyMhlbError(err) {
|
|
22
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
23
|
+
if (msg.includes(NOT_CONFIGURED))
|
|
24
|
+
return { kind: 'no_credential' };
|
|
25
|
+
// The host is unreachable, or MYHOTLUNCHBOX_BASE_URL points somewhere wrong.
|
|
26
|
+
// Nothing here says the credential is bad — do not send anyone to change it.
|
|
27
|
+
if (msg.includes('Could not reach My Hot Lunchbox')) {
|
|
28
|
+
return {
|
|
29
|
+
kind: 'unreachable',
|
|
30
|
+
hint: 'Could not reach My Hot Lunchbox at all, so the credential was never tested. ' +
|
|
31
|
+
'Check network connectivity, or MYHOTLUNCHBOX_BASE_URL if the app has moved.',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (msg.includes('rejected the sign-in')) {
|
|
35
|
+
return {
|
|
36
|
+
kind: 'credential_rejected',
|
|
37
|
+
hint: 'My Hot Lunchbox rejected the sign-in. Check MYHOTLUNCHBOX_USERNAME / MYHOTLUNCHBOX_PASSWORD against ' +
|
|
38
|
+
'https://ordernow.myhotlunchbox.com — but do NOT re-run this with guesses: repeated failures can lock ' +
|
|
39
|
+
'the account or force a CAPTCHA that blocks server-side sign-in entirely.',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
export function registerHealthcheckTools(server, client,
|
|
45
|
+
/** Seam: injectable so tests need no process env. */
|
|
46
|
+
readConfig = () => loadConfig()) {
|
|
47
|
+
registerCredentialHealthcheckTool({
|
|
48
|
+
server,
|
|
49
|
+
prefix: 'mhlb',
|
|
50
|
+
hostLabel: 'ordernow.myhotlunchbox.com',
|
|
51
|
+
probePath: '/api/auth/userinfo',
|
|
52
|
+
resolveCredential: async () => {
|
|
53
|
+
const { username, password, baseUrl } = readConfig();
|
|
54
|
+
// Both halves or nothing: a username with no password cannot sign in,
|
|
55
|
+
// and sending it to the API would spend a failed attempt against an
|
|
56
|
+
// account that locks.
|
|
57
|
+
const source = username && password ? 'MYHOTLUNCHBOX_USERNAME+MYHOTLUNCHBOX_PASSWORD' : null;
|
|
58
|
+
return { source, detail: { base_url: baseUrl } };
|
|
59
|
+
},
|
|
60
|
+
// The same authenticated read `mhlb_whoami` makes: cheap, and it changes
|
|
61
|
+
// nothing — no order is placed, no balance moved.
|
|
62
|
+
probeFn: () => client.get('/auth/userinfo'),
|
|
63
|
+
classifyThrown: classifyMhlbError,
|
|
64
|
+
});
|
|
65
|
+
}
|
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.4.0'; // x-release-please-version
|
package/mint.yaml
CHANGED
|
@@ -33,7 +33,10 @@ env:
|
|
|
33
33
|
help: >-
|
|
34
34
|
Password for that account. Used for a server-side OAuth2 password grant;
|
|
35
35
|
the refresh token it returns renews the session, so no browser or
|
|
36
|
-
extension is involved
|
|
36
|
+
extension is involved. The password itself is never written to disk — the
|
|
37
|
+
token pair it mints is cached at
|
|
38
|
+
$MCP_DATA_DIR/.myhotlunchbox-mcp/token.json (0600), bound to a salted
|
|
39
|
+
digest of these credentials so rotating either discards it.
|
|
37
40
|
|
|
38
41
|
- name: MYHOTLUNCHBOX_BASE_URL
|
|
39
42
|
secret: false
|
|
@@ -52,10 +55,27 @@ env:
|
|
|
52
55
|
the filesystem belongs to the runner, so prefer calling those tools with
|
|
53
56
|
inline: true and taking the bytes back in the result.
|
|
54
57
|
|
|
58
|
+
- name: MYHOTLUNCHBOX_TOKEN_CACHE
|
|
59
|
+
secret: false
|
|
60
|
+
required: false
|
|
61
|
+
help: >-
|
|
62
|
+
Set to false to disable the on-disk token cache and log in on every
|
|
63
|
+
process start. Defaults to enabled.
|
|
64
|
+
- name: MYHOTLUNCHBOX_TOKEN_FILE
|
|
65
|
+
secret: false
|
|
66
|
+
required: false
|
|
67
|
+
help: >-
|
|
68
|
+
Absolute path for the token cache file. Defaults to
|
|
69
|
+
$MCP_DATA_DIR/.myhotlunchbox-mcp/token.json.
|
|
55
70
|
state:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
dataDir: true
|
|
72
|
+
reason: >-
|
|
73
|
+
Caches the OAuth token pair at $MCP_DATA_DIR/.myhotlunchbox-mcp/token.json
|
|
74
|
+
(0600). This was dataDir:false while sessions lived only in memory, on the
|
|
75
|
+
reasoning that a cold start cost one login and lost nothing — true then, and
|
|
76
|
+
no longer: the cache is what turns those starts into nothing, and without a
|
|
77
|
+
persistent dir it is written to a rootfs the next idle-stop discards, so it
|
|
78
|
+
would never be read back.
|
|
59
79
|
|
|
60
80
|
identity:
|
|
61
81
|
# Credentials are per-registration secrets, not per-caller, so one shared
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myhotlunchbox-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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>",
|
|
@@ -38,14 +38,14 @@
|
|
|
38
38
|
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
|
|
39
39
|
"dev": "node dist/index.js",
|
|
40
40
|
"typecheck": "tsc --noEmit",
|
|
41
|
-
"test": "vitest run",
|
|
41
|
+
"test": "npm run typecheck && vitest run",
|
|
42
42
|
"test:watch": "vitest",
|
|
43
|
-
"test:coverage": "vitest run --coverage",
|
|
43
|
+
"test:coverage": "npm run typecheck && vitest run --coverage",
|
|
44
44
|
"verify:reads": "node scripts/verify-reads.mjs",
|
|
45
45
|
"capture:writes": "node scripts/capture-writes.mjs"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@chrischall/mcp-utils": "^0.
|
|
48
|
+
"@chrischall/mcp-utils": "^0.19.3",
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
50
|
"dotenv": "^17.4.0",
|
|
51
51
|
"zod": "^4.4.2"
|
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.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "myhotlunchbox-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -43,6 +43,20 @@
|
|
|
43
43
|
"isRequired": false,
|
|
44
44
|
"format": "string",
|
|
45
45
|
"isSecret": false
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "MYHOTLUNCHBOX_TOKEN_CACHE",
|
|
49
|
+
"description": "Set to false to disable the on-disk token cache and re-authenticate on every process start. Defaults to enabled.",
|
|
50
|
+
"isRequired": false,
|
|
51
|
+
"format": "string",
|
|
52
|
+
"isSecret": false
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "MYHOTLUNCHBOX_TOKEN_FILE",
|
|
56
|
+
"description": "Absolute path for the token cache file. Defaults to $MCP_DATA_DIR/.myhotlunchbox-mcp/token.json.",
|
|
57
|
+
"isRequired": false,
|
|
58
|
+
"format": "string",
|
|
59
|
+
"isSecret": false
|
|
46
60
|
}
|
|
47
61
|
]
|
|
48
62
|
}
|