albert-heijn-mcp 1.3.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/LICENSE +201 -0
- package/README.md +376 -0
- package/assets/icon.png +0 -0
- package/deploy/albert-heijn-mcp.service +42 -0
- package/dist/ahapi/auth.js +18 -0
- package/dist/ahapi/auth.js.map +1 -0
- package/dist/ahapi/bonus.js +53 -0
- package/dist/ahapi/bonus.js.map +1 -0
- package/dist/ahapi/client.js +115 -0
- package/dist/ahapi/client.js.map +1 -0
- package/dist/ahapi/favorites.js +67 -0
- package/dist/ahapi/favorites.js.map +1 -0
- package/dist/ahapi/index.js +12 -0
- package/dist/ahapi/index.js.map +1 -0
- package/dist/ahapi/member.js +23 -0
- package/dist/ahapi/member.js.map +1 -0
- package/dist/ahapi/orders.js +85 -0
- package/dist/ahapi/orders.js.map +1 -0
- package/dist/ahapi/products.js +103 -0
- package/dist/ahapi/products.js.map +1 -0
- package/dist/ahapi/receipts.js +37 -0
- package/dist/ahapi/receipts.js.map +1 -0
- package/dist/ahapi/recipes.js +57 -0
- package/dist/ahapi/recipes.js.map +1 -0
- package/dist/ahapi/shoppingList.js +58 -0
- package/dist/ahapi/shoppingList.js.map +1 -0
- package/dist/ahapi/stores.js +35 -0
- package/dist/ahapi/stores.js.map +1 -0
- package/dist/auth/login.js +15 -0
- package/dist/auth/login.js.map +1 -0
- package/dist/auth/session.js +125 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/tokens.js +132 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -0
- package/dist/index.js +70 -0
- package/dist/index.js.map +1 -0
- package/dist/log.js +70 -0
- package/dist/log.js.map +1 -0
- package/dist/server/http.js +99 -0
- package/dist/server/http.js.map +1 -0
- package/dist/tools/account.js +126 -0
- package/dist/tools/account.js.map +1 -0
- package/dist/tools/bonus.js +151 -0
- package/dist/tools/bonus.js.map +1 -0
- package/dist/tools/cache.js +44 -0
- package/dist/tools/cache.js.map +1 -0
- package/dist/tools/cart.js +127 -0
- package/dist/tools/cart.js.map +1 -0
- package/dist/tools/common.js +173 -0
- package/dist/tools/common.js.map +1 -0
- package/dist/tools/favorites.js +103 -0
- package/dist/tools/favorites.js.map +1 -0
- package/dist/tools/index.js +24 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/orders.js +141 -0
- package/dist/tools/orders.js.map +1 -0
- package/dist/tools/products.js +165 -0
- package/dist/tools/products.js.map +1 -0
- package/dist/tools/receipts.js +52 -0
- package/dist/tools/receipts.js.map +1 -0
- package/dist/tools/recipes.js +261 -0
- package/dist/tools/recipes.js.map +1 -0
- package/dist/tools/shoppingList.js +149 -0
- package/dist/tools/shoppingList.js.map +1 -0
- package/dist/tools/stores.js +108 -0
- package/dist/tools/stores.js.map +1 -0
- package/dist/tools/views.js +100 -0
- package/dist/tools/views.js.map +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Shopping list (boodschappenlijst). Returns "Server in order mode" while a delivery order is active. */
|
|
2
|
+
const SHOPPING_LIST_PATH = "/mobile-services/shoppinglist/v2/items";
|
|
3
|
+
/** Reports whether err is the "Server in order mode" response. */
|
|
4
|
+
export function isOrderMode(err) {
|
|
5
|
+
return err instanceof Error && err.message.includes("Server in order mode");
|
|
6
|
+
}
|
|
7
|
+
export async function getShoppingList(c) {
|
|
8
|
+
const res = await c.request(SHOPPING_LIST_PATH);
|
|
9
|
+
return (res.items ?? []).map((it) => {
|
|
10
|
+
const product = it.productDetails?.product;
|
|
11
|
+
return {
|
|
12
|
+
productId: product?.webshopId ?? 0,
|
|
13
|
+
name: product?.title || it.description || "",
|
|
14
|
+
quantity: it.quantity,
|
|
15
|
+
position: it.position,
|
|
16
|
+
checked: it.strikedthrough ?? false,
|
|
17
|
+
raw: { type: it.type, originCode: it.originCode, description: it.description },
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function patchShoppingList(c, items) {
|
|
22
|
+
return c.request(SHOPPING_LIST_PATH, { method: "PATCH", body: { items } });
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Adds products (product ID → units to add). The PATCH sets quantities rather
|
|
26
|
+
* than adding to them, so products already on the list are sent with their
|
|
27
|
+
* current quantity plus the units added.
|
|
28
|
+
*/
|
|
29
|
+
export async function addProductsToShoppingList(c, quantities) {
|
|
30
|
+
const current = new Map((await getShoppingList(c)).filter((it) => it.productId).map((it) => [it.productId, it.quantity]));
|
|
31
|
+
const items = [...quantities].map(([productId, quantity]) => ({
|
|
32
|
+
description: "",
|
|
33
|
+
productId,
|
|
34
|
+
quantity: (current.get(productId) ?? 0) + quantity,
|
|
35
|
+
type: "SHOPPABLE",
|
|
36
|
+
originCode: "PRD",
|
|
37
|
+
strikeThrough: false,
|
|
38
|
+
}));
|
|
39
|
+
return patchShoppingList(c, items);
|
|
40
|
+
}
|
|
41
|
+
/** Adds a free-text item. AH stores free-text items with quantity 1 and ignores other quantities. */
|
|
42
|
+
export function addTextToShoppingList(c, text) {
|
|
43
|
+
return patchShoppingList(c, [{ description: text, quantity: 1, type: "SHOPPABLE", originCode: "PRD", strikeThrough: false }]);
|
|
44
|
+
}
|
|
45
|
+
/** Removes items from getShoppingList by setting their quantity to 0. */
|
|
46
|
+
export function removeFromShoppingList(c, items) {
|
|
47
|
+
return patchShoppingList(c, items.map((it) => ({
|
|
48
|
+
// AH matches product items by productId and free-text items by description.
|
|
49
|
+
// GET omits type and originCode; use the values sent when adding.
|
|
50
|
+
description: it.raw.description || it.name,
|
|
51
|
+
...(it.productId ? { productId: it.productId } : {}),
|
|
52
|
+
quantity: 0,
|
|
53
|
+
type: it.raw.type || "SHOPPABLE",
|
|
54
|
+
originCode: it.raw.originCode || "PRD",
|
|
55
|
+
strikeThrough: false,
|
|
56
|
+
})));
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=shoppingList.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shoppingList.js","sourceRoot":"","sources":["../../src/ahapi/shoppingList.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Stores nearest to a Dutch postal code. */
|
|
2
|
+
export async function searchStores(c, postalCode) {
|
|
3
|
+
const query = `query SearchStores($filter: StoresFilterInput) {
|
|
4
|
+
storesSearch(filter: $filter, limit: 5) {
|
|
5
|
+
result { id name storeType address { street houseNumber postalCode city } }
|
|
6
|
+
}
|
|
7
|
+
}`;
|
|
8
|
+
const data = await c.graphql(query, { filter: { postalCode } });
|
|
9
|
+
return data.storesSearch.result ?? [];
|
|
10
|
+
}
|
|
11
|
+
export async function getBargains(c, storeId) {
|
|
12
|
+
const query = `query Bargains($storeId: String!) {
|
|
13
|
+
bargainItems(storeId: $storeId) {
|
|
14
|
+
product { id title brand }
|
|
15
|
+
categoryTitle
|
|
16
|
+
stock
|
|
17
|
+
markdown { markdownType markdownExpirationDate markdownPercentage }
|
|
18
|
+
bargainPrice { priceWas priceNow }
|
|
19
|
+
}
|
|
20
|
+
}`;
|
|
21
|
+
const data = await c.graphql(query, { storeId: String(storeId) });
|
|
22
|
+
return (data.bargainItems ?? []).map((b) => ({
|
|
23
|
+
productId: b.product.id,
|
|
24
|
+
title: b.product.title,
|
|
25
|
+
brand: b.product.brand,
|
|
26
|
+
category: b.categoryTitle,
|
|
27
|
+
markdownType: b.markdown?.markdownType,
|
|
28
|
+
discount: b.markdown?.markdownPercentage,
|
|
29
|
+
expirationDate: b.markdown?.markdownExpirationDate,
|
|
30
|
+
stock: b.stock,
|
|
31
|
+
priceWas: b.bargainPrice?.priceWas,
|
|
32
|
+
priceNow: b.bargainPrice?.priceNow,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=stores.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stores.js","sourceRoot":"","sources":["../../src/ahapi/stores.ts"],"names":[],"mappings":"AASA,6CAA6C;AAC7C,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,CAAW,EAAE,UAAkB;IAChE,MAAM,KAAK,GAAG;;;;EAId,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,OAAO,CAA+C,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,EAAE,CAAC,CAAC;IAC9G,OAAO,IAAI,CAAC,YAAY,CAAC,MAAM,IAAI,EAAE,CAAC;AACxC,CAAC;AAiBD,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,CAAW,EAAE,OAAe;IAC5D,MAAM,KAAK,GAAG;;;;;;;;EAQd,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,OAAO,CAQzB,KAAK,EAAE,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IACxC,OAAO,CAAC,IAAI,CAAC,YAAY,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QAC3C,SAAS,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE;QACvB,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK;QACtB,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK;QACtB,QAAQ,EAAE,CAAC,CAAC,aAAa;QACzB,YAAY,EAAE,CAAC,CAAC,QAAQ,EAAE,YAAY;QACtC,QAAQ,EAAE,CAAC,CAAC,QAAQ,EAAE,kBAAkB;QACxC,cAAc,EAAE,CAAC,CAAC,QAAQ,EAAE,sBAAsB;QAClD,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,QAAQ,EAAE,CAAC,CAAC,YAAY,EAAE,QAAQ;QAClC,QAAQ,EAAE,CAAC,CAAC,YAAY,EAAE,QAAQ;KACnC,CAAC,CAAC,CAAC;AACN,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** code=... in a pasted URL or console line. */
|
|
2
|
+
const CODE_PARAM = /[?&]code=([A-Za-z0-9._~-]+)/;
|
|
3
|
+
/** A bare code. */
|
|
4
|
+
const BARE_CODE = /^[A-Za-z0-9._~-]{8,}$/;
|
|
5
|
+
/** Extracts the login code from the pasted redirect link, console line, or bare code. */
|
|
6
|
+
export function extractCode(input) {
|
|
7
|
+
const trimmed = input.trim();
|
|
8
|
+
const match = CODE_PARAM.exec(trimmed);
|
|
9
|
+
if (match?.[1])
|
|
10
|
+
return match[1];
|
|
11
|
+
if (BARE_CODE.test(trimmed))
|
|
12
|
+
return trimmed;
|
|
13
|
+
throw new Error("no login code found: paste the appie://login-exit?code=... link or the code itself");
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=login.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.js","sourceRoot":"","sources":["../../src/auth/login.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,MAAM,UAAU,GAAG,6BAA6B,CAAC;AACjD,mBAAmB;AACnB,MAAM,SAAS,GAAG,uBAAuB,CAAC;AAE1C,yFAAyF;AACzF,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACvC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC;IAChC,IAAI,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,OAAO,CAAC;IAC5C,MAAM,IAAI,KAAK,CAAC,oFAAoF,CAAC,CAAC;AACxG,CAAC"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { AhClient, ContextError, exchangeCode, hasStatus, refreshToken, wrapError } from "../ahapi/index.js";
|
|
3
|
+
import { loadTokens, saveTokens, toTokenFile, withFileLock } from "./tokens.js";
|
|
4
|
+
/** Refresh this long before the access token expires. */
|
|
5
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
6
|
+
/** No tokens are stored. */
|
|
7
|
+
export class NotLoggedInError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super("not logged in");
|
|
10
|
+
this.name = "NotLoggedInError";
|
|
11
|
+
}
|
|
12
|
+
logText() {
|
|
13
|
+
return this.message;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** AH rejected the stored refresh token (expired or revoked): the user must log in again. */
|
|
17
|
+
export class SessionExpiredError extends ContextError {
|
|
18
|
+
constructor(cause) {
|
|
19
|
+
super("token refresh failed; log in again", cause);
|
|
20
|
+
this.name = "SessionExpiredError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function expiresSoon(tf) {
|
|
24
|
+
// No refresh if AH sent no lifetime: NaN compares false.
|
|
25
|
+
return Date.parse(tf.expires_at ?? "") - Date.now() < REFRESH_MARGIN_MS;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Login session: stored tokens and API clients built from them.
|
|
29
|
+
*
|
|
30
|
+
* Token writes are serialised, within this process and across all processes
|
|
31
|
+
* sharing the tokens file: refresh tokens rotate, so parallel refreshes would
|
|
32
|
+
* race, and a pending refresh must not undo a logout or login.
|
|
33
|
+
* Tokens are re-read on every call to pick up refreshes by other processes.
|
|
34
|
+
*/
|
|
35
|
+
export class Session {
|
|
36
|
+
tokensPath;
|
|
37
|
+
#lock = Promise.resolve();
|
|
38
|
+
constructor(tokensPath) {
|
|
39
|
+
this.tokensPath = tokensPath;
|
|
40
|
+
}
|
|
41
|
+
#withLock(fn) {
|
|
42
|
+
const locked = () => withFileLock(this.tokensPath, fn);
|
|
43
|
+
const run = this.#lock.then(locked, locked);
|
|
44
|
+
this.#lock = run.catch(() => { });
|
|
45
|
+
return run;
|
|
46
|
+
}
|
|
47
|
+
/** Reports whether a refresh token is stored. */
|
|
48
|
+
async isAuthenticated() {
|
|
49
|
+
try {
|
|
50
|
+
return Boolean((await loadTokens(this.tokensPath))?.refresh_token);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Returns an authenticated client, refreshing the token first if it expires soon. */
|
|
57
|
+
async client() {
|
|
58
|
+
// Lock only to refresh: another process may have refreshed meanwhile, so re-check.
|
|
59
|
+
const tf = await this.#requireTokens();
|
|
60
|
+
if (!expiresSoon(tf))
|
|
61
|
+
return new AhClient(tf.access_token);
|
|
62
|
+
return this.#withLock(async () => {
|
|
63
|
+
let fresh = await this.#requireTokens();
|
|
64
|
+
if (expiresSoon(fresh))
|
|
65
|
+
fresh = await this.#refresh(fresh);
|
|
66
|
+
return new AhClient(fresh.access_token);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** After AH rejected staleAccessToken: refreshes (unless already replaced) and returns a new client. */
|
|
70
|
+
refreshAfterRejection(staleAccessToken) {
|
|
71
|
+
return this.#withLock(async () => {
|
|
72
|
+
let tf = await this.#requireTokens();
|
|
73
|
+
if (tf.access_token === staleAccessToken)
|
|
74
|
+
tf = await this.#refresh(tf);
|
|
75
|
+
return new AhClient(tf.access_token);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async #requireTokens() {
|
|
79
|
+
const tf = await loadTokens(this.tokensPath);
|
|
80
|
+
if (!tf?.refresh_token)
|
|
81
|
+
throw new NotLoggedInError();
|
|
82
|
+
return tf;
|
|
83
|
+
}
|
|
84
|
+
async #refresh(tf) {
|
|
85
|
+
let tok;
|
|
86
|
+
try {
|
|
87
|
+
tok = await refreshToken(new AhClient(), tf.refresh_token);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// 400/401 mean the refresh token is no good; anything else may be temporary.
|
|
91
|
+
throw hasStatus(err, 400) || hasStatus(err, 401) ? new SessionExpiredError(err) : wrapError("token refresh failed", err);
|
|
92
|
+
}
|
|
93
|
+
const next = toTokenFile(tok);
|
|
94
|
+
await saveTokens(this.tokensPath, next);
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
/** Exchanges a login code for tokens and saves them. */
|
|
98
|
+
completeLogin(code) {
|
|
99
|
+
return this.#withLock(async () => {
|
|
100
|
+
let tok;
|
|
101
|
+
try {
|
|
102
|
+
tok = await exchangeCode(new AhClient(), code);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
throw wrapError("exchange code (codes are single-use and expire quickly; log in again for a new one)", err);
|
|
106
|
+
}
|
|
107
|
+
await saveTokens(this.tokensPath, toTokenFile(tok));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Deletes the tokens file, even if corrupt. Returns whether it existed. */
|
|
111
|
+
logout() {
|
|
112
|
+
return this.#withLock(async () => {
|
|
113
|
+
try {
|
|
114
|
+
await rm(this.tokensPath);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code === "ENOENT")
|
|
119
|
+
return false;
|
|
120
|
+
throw wrapError("remove tokens", err);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/auth/session.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { link, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
/** <user config dir>/albert-heijn-mcp/tokens.json, per OS convention. */
|
|
6
|
+
export function defaultTokensPath() {
|
|
7
|
+
let configDir;
|
|
8
|
+
if (process.platform === "darwin") {
|
|
9
|
+
configDir = join(homedir(), "Library", "Application Support");
|
|
10
|
+
}
|
|
11
|
+
else if (process.platform === "win32") {
|
|
12
|
+
configDir = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
16
|
+
}
|
|
17
|
+
return join(configDir, "albert-heijn-mcp", "tokens.json");
|
|
18
|
+
}
|
|
19
|
+
/** Token response → file format. */
|
|
20
|
+
export function toTokenFile(t) {
|
|
21
|
+
const tf = { access_token: t.access_token, refresh_token: t.refresh_token };
|
|
22
|
+
if (t.expires_in)
|
|
23
|
+
tf.expires_at = new Date(Date.now() + t.expires_in * 1000).toISOString();
|
|
24
|
+
return tf;
|
|
25
|
+
}
|
|
26
|
+
/** Returns undefined if the file does not exist. */
|
|
27
|
+
export async function loadTokens(path) {
|
|
28
|
+
let data;
|
|
29
|
+
try {
|
|
30
|
+
data = await readFile(path, "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (err.code === "ENOENT")
|
|
34
|
+
return undefined;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
return JSON.parse(data);
|
|
38
|
+
}
|
|
39
|
+
/** Writes atomically with mode 0600. */
|
|
40
|
+
export async function saveTokens(path, tf) {
|
|
41
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
42
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
43
|
+
await writeFile(tmp, JSON.stringify(tf, null, 2), { mode: 0o600 });
|
|
44
|
+
try {
|
|
45
|
+
await rename(tmp, path);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
await rm(tmp, { force: true });
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** A lock older than this is from a crashed process (a refresh request times out after 30 s). */
|
|
53
|
+
const LOCK_STALE_MS = 60_000;
|
|
54
|
+
const LOCK_WAIT_MS = 70_000;
|
|
55
|
+
/**
|
|
56
|
+
* Runs fn holding an exclusive lock on the tokens file, shared by all albert-heijn-mcp
|
|
57
|
+
* processes using it. Refresh tokens rotate, so two processes must not refresh at once.
|
|
58
|
+
* The lock file holds a random ID that identifies its owner (inode numbers get reused).
|
|
59
|
+
*/
|
|
60
|
+
export async function withFileLock(path, fn) {
|
|
61
|
+
const lock = `${path}.lock`;
|
|
62
|
+
const id = randomUUID();
|
|
63
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
64
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
65
|
+
for (;;) {
|
|
66
|
+
try {
|
|
67
|
+
await writeFile(lock, id, { flag: "wx", mode: 0o600 });
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (err.code !== "EEXIST")
|
|
72
|
+
throw err;
|
|
73
|
+
if (await removeIfStale(lock))
|
|
74
|
+
continue;
|
|
75
|
+
if (Date.now() > deadline)
|
|
76
|
+
throw new Error(`timed out waiting for ${lock}`);
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return await fn();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
// Delete the lock only if it is still ours (not taken over as stale).
|
|
85
|
+
if ((await readLockId(lock)) === id)
|
|
86
|
+
await rm(lock, { force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The lock's owner ID; undefined if the lock is gone, null if it can't be read. */
|
|
90
|
+
async function readLockId(lock) {
|
|
91
|
+
try {
|
|
92
|
+
return await readFile(lock, "utf8");
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
return err.code === "ENOENT" ? undefined : null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Removes lock if it is stale; returns true if the caller should retry at once.
|
|
100
|
+
* The lock is first renamed aside, which only one process can do for a given
|
|
101
|
+
* file. If the file moved is a fresh lock that replaced the stale one in the
|
|
102
|
+
* meantime (a different owner ID), it is put back.
|
|
103
|
+
*/
|
|
104
|
+
async function removeIfStale(lock) {
|
|
105
|
+
// Read the ID before checking the age: a lock that replaced the stale one is
|
|
106
|
+
// fresh, so its ID is never taken for the stale one's.
|
|
107
|
+
const staleId = await readLockId(lock);
|
|
108
|
+
if (staleId === undefined)
|
|
109
|
+
return true;
|
|
110
|
+
// Unreadable (e.g. left by another user): treat as held, so the caller waits and times out.
|
|
111
|
+
if (staleId === null)
|
|
112
|
+
return false;
|
|
113
|
+
const seen = await stat(lock).catch(() => undefined);
|
|
114
|
+
if (!seen)
|
|
115
|
+
return true;
|
|
116
|
+
if (Date.now() - seen.mtimeMs <= LOCK_STALE_MS)
|
|
117
|
+
return false;
|
|
118
|
+
const aside = `${lock}.${process.pid}.${randomUUID()}`;
|
|
119
|
+
try {
|
|
120
|
+
await rename(lock, aside);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (err.code === "ENOENT")
|
|
124
|
+
return true;
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
if ((await readLockId(aside)) !== staleId)
|
|
128
|
+
await link(aside, lock).catch(() => { });
|
|
129
|
+
await rm(aside, { force: true });
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=tokens.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"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"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { defaultTokensPath } from "./auth/tokens.js";
|
|
4
|
+
const TRANSPORTS = ["stdio", "streamable-http"];
|
|
5
|
+
export const USAGE = `Usage: albert-heijn-mcp [--transport stdio|streamable-http] [--remote] [--version] [--help]
|
|
6
|
+
|
|
7
|
+
--transport stdio (default) or streamable-http
|
|
8
|
+
--remote don't open a browser on login (also AH_REMOTE=true)
|
|
9
|
+
|
|
10
|
+
Settings come from environment variables, also read from a .env file in the
|
|
11
|
+
working directory. See README.md.`;
|
|
12
|
+
/** Reads flags and environment variables. */
|
|
13
|
+
export function loadConfig(argv, env = process.env) {
|
|
14
|
+
const { values } = parseArgs({
|
|
15
|
+
args: argv,
|
|
16
|
+
options: {
|
|
17
|
+
transport: { type: "string", default: "stdio" },
|
|
18
|
+
remote: { type: "boolean", default: false },
|
|
19
|
+
version: { type: "boolean", default: false },
|
|
20
|
+
help: { type: "boolean", short: "h", default: false },
|
|
21
|
+
},
|
|
22
|
+
strict: true,
|
|
23
|
+
});
|
|
24
|
+
const transport = values.transport;
|
|
25
|
+
if (!TRANSPORTS.includes(transport)) {
|
|
26
|
+
throw new Error(`unknown transport "${values.transport}" (use ${TRANSPORTS.join(" or ")})`);
|
|
27
|
+
}
|
|
28
|
+
const port = envInt(env, "AH_MCP_PORT", 3000);
|
|
29
|
+
const baseUrl = env.AH_MCP_BASE_URL || `http://localhost:${port}`;
|
|
30
|
+
if (!URL.canParse(baseUrl))
|
|
31
|
+
throw new Error(`AH_MCP_BASE_URL: "${baseUrl}" is not a URL (e.g. https://albert-heijn-mcp.example.com)`);
|
|
32
|
+
return {
|
|
33
|
+
transport,
|
|
34
|
+
remote: values.remote || env.AH_REMOTE === "true",
|
|
35
|
+
showVersion: values.version,
|
|
36
|
+
showHelp: values.help,
|
|
37
|
+
tokensPath: env.AH_TOKENS_PATH || defaultTokensPath(),
|
|
38
|
+
logFile: env.AH_LOG_FILE || undefined,
|
|
39
|
+
http: {
|
|
40
|
+
host: env.AH_MCP_HOST || "127.0.0.1",
|
|
41
|
+
port,
|
|
42
|
+
baseUrl,
|
|
43
|
+
token: env.AH_MCP_TOKEN || undefined,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function envInt(env, key, def) {
|
|
48
|
+
const v = env[key];
|
|
49
|
+
if (!v)
|
|
50
|
+
return def;
|
|
51
|
+
const n = Number(v);
|
|
52
|
+
if (!Number.isInteger(n))
|
|
53
|
+
throw new Error(`${key}: "${v}" is not a number`);
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
/** Loads ./.env if present; existing variables take precedence. */
|
|
57
|
+
export function loadDotEnv(path = ".env") {
|
|
58
|
+
if (existsSync(path))
|
|
59
|
+
process.loadEnvFile(path);
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD,MAAM,UAAU,GAAG,CAAC,OAAO,EAAE,iBAAiB,CAAU,CAAC;AAwBzD,MAAM,CAAC,MAAM,KAAK,GAAG;;;;;;kCAMa,CAAC;AAEnC,6CAA6C;AAC7C,MAAM,UAAU,UAAU,CAAC,IAAc,EAAE,GAAG,GAAsB,OAAO,CAAC,GAAG;IAC7E,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;QAC3B,IAAI,EAAE,IAAI;QACV,OAAO,EAAE;YACP,SAAS,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE;YAC/C,MAAM,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE;YAC3C,OAAO,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE;YAC5C,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE;SACtD;QACD,MAAM,EAAE,IAAI;KACb,CAAC,CAAC;IACH,MAAM,SAAS,GAAG,MAAM,CAAC,SAAsB,CAAC;IAChD,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;QACpC,MAAM,IAAI,KAAK,CAAC,sBAAsB,MAAM,CAAC,SAAS,UAAU,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC9F,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,EAAE,aAAa,EAAE,IAAI,CAAC,CAAC;IAC9C,MAAM,OAAO,GAAG,GAAG,CAAC,eAAe,IAAI,oBAAoB,IAAI,EAAE,CAAC;IAClE,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,qBAAqB,OAAO,4DAA4D,CAAC,CAAC;IACtI,OAAO;QACL,SAAS;QACT,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,SAAS,KAAK,MAAM;QACjD,WAAW,EAAE,MAAM,CAAC,OAAO;QAC3B,QAAQ,EAAE,MAAM,CAAC,IAAI;QACrB,UAAU,EAAE,GAAG,CAAC,cAAc,IAAI,iBAAiB,EAAE;QACrD,OAAO,EAAE,GAAG,CAAC,WAAW,IAAI,SAAS;QACrC,IAAI,EAAE;YACJ,IAAI,EAAE,GAAG,CAAC,WAAW,IAAI,WAAW;YACpC,IAAI;YACJ,OAAO;YACP,KAAK,EAAE,GAAG,CAAC,YAAY,IAAI,SAAS;SACrC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,MAAM,CAAC,GAAsB,EAAE,GAAW,EAAE,GAAW;IAC9D,MAAM,CAAC,GAAG,GAAG,CAAC,GAAG,CAAC,CAAC;IACnB,IAAI,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC;IACnB,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;IACpB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,GAAG,GAAG,MAAM,CAAC,mBAAmB,CAAC,CAAC;IAC5E,OAAO,CAAC,CAAC;AACX,CAAC;AAED,mEAAmE;AACnE,MAAM,UAAU,UAAU,CAAC,IAAI,GAAG,MAAM;IACtC,IAAI,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;AAClD,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// albert-heijn-mcp: a Model Context Protocol server for the Albert Heijn supermarket API.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { Session } from "./auth/session.js";
|
|
7
|
+
import { loadConfig, loadDotEnv, USAGE } from "./config.js";
|
|
8
|
+
import { closeLog, log, logToFile } from "./log.js";
|
|
9
|
+
import { serveHttp } from "./server/http.js";
|
|
10
|
+
import { newToolContext, registerTools } from "./tools/index.js";
|
|
11
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
12
|
+
/** Shown by clients that display server icons. */
|
|
13
|
+
const ICON = {
|
|
14
|
+
src: `data:image/png;base64,${readFileSync(new URL("../assets/icon.png", import.meta.url)).toString("base64")}`,
|
|
15
|
+
mimeType: "image/png",
|
|
16
|
+
sizes: ["128x128"],
|
|
17
|
+
};
|
|
18
|
+
/** Sent to clients on connect. */
|
|
19
|
+
const INSTRUCTIONS = "When you mention an Albert Heijn product or recipe to the user, link its name to its page: " +
|
|
20
|
+
"use the url field from the tool result, e.g. [AH Halfvolle melk](https://www.ah.nl/producten/product/wi...).";
|
|
21
|
+
async function main() {
|
|
22
|
+
loadDotEnv();
|
|
23
|
+
const cfg = loadConfig(process.argv.slice(2));
|
|
24
|
+
if (cfg.showHelp) {
|
|
25
|
+
console.log(USAGE);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (cfg.showVersion) {
|
|
29
|
+
console.log(`albert-heijn-mcp ${version}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (cfg.logFile)
|
|
33
|
+
await logToFile(cfg.logFile);
|
|
34
|
+
log.info("albert-heijn-mcp", { version });
|
|
35
|
+
const ctx = newToolContext(new Session(cfg.tokensPath), cfg.remote);
|
|
36
|
+
const newServer = () => {
|
|
37
|
+
const server = new McpServer({ name: "Albert Heijn", version, icons: [ICON] }, { instructions: INSTRUCTIONS });
|
|
38
|
+
registerTools(server, ctx);
|
|
39
|
+
return server;
|
|
40
|
+
};
|
|
41
|
+
if (cfg.transport === "stdio") {
|
|
42
|
+
log.info("starting server", { transport: "stdio" });
|
|
43
|
+
await newServer().connect(new StdioServerTransport());
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const httpServer = await serveHttp(newServer, cfg.http);
|
|
47
|
+
const shutdown = () => {
|
|
48
|
+
log.info("shutting down");
|
|
49
|
+
httpServer.close(() => exit(0));
|
|
50
|
+
setTimeout(() => exit(0), 5000).unref();
|
|
51
|
+
};
|
|
52
|
+
process.once("SIGTERM", shutdown);
|
|
53
|
+
process.once("SIGINT", shutdown);
|
|
54
|
+
}
|
|
55
|
+
/** Exits once the log file has the last lines. */
|
|
56
|
+
function exit(code) {
|
|
57
|
+
void closeLog().then(() => process.exit(code));
|
|
58
|
+
}
|
|
59
|
+
// Node would print these in full, possibly quoting AH's responses; log them safely instead.
|
|
60
|
+
for (const event of ["uncaughtException", "unhandledRejection"]) {
|
|
61
|
+
process.on(event, (err) => {
|
|
62
|
+
log.error(event, { err });
|
|
63
|
+
exit(1);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
main().catch((err) => {
|
|
67
|
+
log.error("fatal", { err });
|
|
68
|
+
exit(1);
|
|
69
|
+
});
|
|
70
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
let file;
|
|
3
|
+
/** Also appends logs to the file at path. */
|
|
4
|
+
export function logToFile(path) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const stream = createWriteStream(path, { flags: "a", mode: 0o600 });
|
|
7
|
+
stream.once("open", () => {
|
|
8
|
+
file = stream;
|
|
9
|
+
resolve();
|
|
10
|
+
});
|
|
11
|
+
stream.once("error", reject);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* err as safe log text: our own messages and AH's status codes, never AH's
|
|
16
|
+
* response text or other data from outside, which may hold customer data.
|
|
17
|
+
*/
|
|
18
|
+
export function describeError(err) {
|
|
19
|
+
if (typeof err?.logText === "function")
|
|
20
|
+
return err.logText();
|
|
21
|
+
if (!(err instanceof Error))
|
|
22
|
+
return `non-error value (${typeof err})`;
|
|
23
|
+
// Plain errors are ours; others (e.g. a JSON parse error) may quote what they failed on.
|
|
24
|
+
if (err.constructor === Error)
|
|
25
|
+
return err.message;
|
|
26
|
+
const code = errorCode(err) ?? errorCode(err.cause);
|
|
27
|
+
return code ? `${err.name} ${code}` : err.name;
|
|
28
|
+
}
|
|
29
|
+
function errorCode(err) {
|
|
30
|
+
const code = err?.code;
|
|
31
|
+
return typeof code === "string" ? code : undefined;
|
|
32
|
+
}
|
|
33
|
+
function format(value) {
|
|
34
|
+
const s = String(value);
|
|
35
|
+
return /[\s"=]/.test(s) ? JSON.stringify(s) : s;
|
|
36
|
+
}
|
|
37
|
+
function write(level, msg, attrs) {
|
|
38
|
+
const parts = [`time=${new Date().toISOString()}`, `level=${level}`, `msg=${format(msg)}`];
|
|
39
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
40
|
+
// Errors, and whatever was thrown under err, are described safely.
|
|
41
|
+
const safe = value instanceof Error || key === "err" ? describeError(value) : value;
|
|
42
|
+
if (value !== undefined)
|
|
43
|
+
parts.push(`${key}=${format(safe)}`);
|
|
44
|
+
}
|
|
45
|
+
const line = `${parts.join(" ")}\n`;
|
|
46
|
+
process.stderr.write(line);
|
|
47
|
+
file?.write(line);
|
|
48
|
+
}
|
|
49
|
+
let closing;
|
|
50
|
+
/** Flushes and closes the log file, waiting at most 1 s; call before exiting. Every call waits for the same flush. */
|
|
51
|
+
export function closeLog() {
|
|
52
|
+
closing ??= new Promise((resolve) => {
|
|
53
|
+
const f = file;
|
|
54
|
+
file = undefined;
|
|
55
|
+
if (!f)
|
|
56
|
+
return resolve();
|
|
57
|
+
const timer = setTimeout(resolve, 1000);
|
|
58
|
+
f.end(() => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
return closing;
|
|
64
|
+
}
|
|
65
|
+
export const log = {
|
|
66
|
+
info: (msg, attrs = {}) => write("INFO", msg, attrs),
|
|
67
|
+
warn: (msg, attrs = {}) => write("WARN", msg, attrs),
|
|
68
|
+
error: (msg, attrs = {}) => write("ERROR", msg, attrs),
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=log.js.map
|
package/dist/log.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"log.js","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"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"}
|