picnic-mcp 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +63 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +89 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/format/cart.d.ts +47 -0
- package/dist/format/cart.js +57 -0
- package/dist/format/cart.js.map +1 -0
- package/dist/format/prices.d.ts +5 -0
- package/dist/format/prices.js +27 -0
- package/dist/format/prices.js.map +1 -0
- package/dist/format/products.d.ts +27 -0
- package/dist/format/products.js +25 -0
- package/dist/format/products.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/match.d.ts +33 -0
- package/dist/match.js +44 -0
- package/dist/match.js.map +1 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +34 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/cart.d.ts +3 -0
- package/dist/tools/cart.js +67 -0
- package/dist/tools/cart.js.map +1 -0
- package/dist/tools/context.d.ts +10 -0
- package/dist/tools/context.js +7 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/result.d.ts +6 -0
- package/dist/tools/result.js +19 -0
- package/dist/tools/result.js.map +1 -0
- package/dist/tools/search-products.d.ts +3 -0
- package/dist/tools/search-products.js +52 -0
- package/dist/tools/search-products.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +59 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MRVDH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# picnic-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for the online supermarket [Picnic](https://picnic.app). It lets a model search Picnic's products and change your shopping cart. It can't place an order.
|
|
4
|
+
|
|
5
|
+
Unofficial: not made or supported by Picnic. Built on [picnic-api](https://github.com/MRVDH/picnic-api).
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | What it does |
|
|
10
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| `search_products` | Searches products. Returns a `bestMatch` when one product fits (usually one you bought before), or 2 or 3 `choices` to ask you about. |
|
|
12
|
+
| `add_to_cart` | Adds a product by its id. Returns the quantity in the cart and the cart total, and replacements when the product can't be delivered. |
|
|
13
|
+
| `remove_from_cart` | Removes a product by its id. |
|
|
14
|
+
| `get_cart` | Shows the cart: products, quantities, prices, total and delivery window, and replacements for products that can't be delivered. |
|
|
15
|
+
|
|
16
|
+
Output is compact JSON with only what a model needs. It contains products, prices and your cart, but not your name, address or other account details. Keep in mind that whatever the tools return is sent to the model your MCP client uses.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
Requires Node.js 20 or later.
|
|
21
|
+
|
|
22
|
+
### 1. Get an auth key
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx picnic-mcp login
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
It asks for your country, email and password, and the code Picnic texts you when your account uses two-factor authentication. It prints an auth key and stores nothing. Keep the key secret: it gives access to your Picnic account.
|
|
29
|
+
|
|
30
|
+
### 2. Add the server to your MCP client
|
|
31
|
+
|
|
32
|
+
Most clients take a configuration like this:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"picnic": {
|
|
38
|
+
"command": "npx",
|
|
39
|
+
"args": ["-y", "picnic-mcp"],
|
|
40
|
+
"env": {
|
|
41
|
+
"PICNIC_AUTH_KEY": "<your auth key>",
|
|
42
|
+
"PICNIC_COUNTRY_CODE": "NL"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`PICNIC_COUNTRY_CODE` is `NL`, `DE` or `FR` (default `NL`). The server runs over stdio.
|
|
50
|
+
|
|
51
|
+
When the key expires, the tools say so; run `npx picnic-mcp login` again.
|
|
52
|
+
|
|
53
|
+
## Use it in your own code
|
|
54
|
+
|
|
55
|
+
`createServer` returns the MCP server without reading environment variables or files, so you can run it in your own process and connect it to any MCP transport:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { createServer } from "picnic-mcp";
|
|
59
|
+
|
|
60
|
+
const server = createServer({ authKey: "<auth key>", countryCode: "NL" });
|
|
61
|
+
await server.connect(transport);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Pass `client` instead of `authKey` to use a picnic-api client you already have, and `onError` to handle errors from Picnic yourself (by default they're logged to stderr).
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { Writable } from "node:stream";
|
|
5
|
+
import PicnicClient from "picnic-api";
|
|
6
|
+
import { createServer } from "./server.js";
|
|
7
|
+
import { COUNTRY_CODES } from "./types.js";
|
|
8
|
+
const USAGE = `Usage:
|
|
9
|
+
picnic-mcp Serve the MCP server over stdio. Reads PICNIC_AUTH_KEY and
|
|
10
|
+
PICNIC_COUNTRY_CODE (NL, DE or FR; default NL).
|
|
11
|
+
picnic-mcp login Sign in to Picnic and print an auth key for PICNIC_AUTH_KEY.`;
|
|
12
|
+
function parseCountryCode(value) {
|
|
13
|
+
const code = (value ?? "NL").trim().toUpperCase();
|
|
14
|
+
if (!COUNTRY_CODES.includes(code)) {
|
|
15
|
+
throw new Error(`Unknown country code "${value}". Use one of ${COUNTRY_CODES.join(", ")}.`);
|
|
16
|
+
}
|
|
17
|
+
return code;
|
|
18
|
+
}
|
|
19
|
+
async function serve() {
|
|
20
|
+
const authKey = process.env.PICNIC_AUTH_KEY?.trim();
|
|
21
|
+
if (!authKey) {
|
|
22
|
+
console.error("PICNIC_AUTH_KEY is not set. Get an auth key with `npx picnic-mcp login`.\n\n" + USAGE);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const countryCode = parseCountryCode(process.env.PICNIC_COUNTRY_CODE);
|
|
26
|
+
serveStdio(() => createServer({ authKey, countryCode }));
|
|
27
|
+
}
|
|
28
|
+
/** Asks for the login details on the terminal and prints the auth key. Stores nothing. */
|
|
29
|
+
async function login() {
|
|
30
|
+
// Output goes to stderr, so stdout carries only the auth key.
|
|
31
|
+
let muted = false;
|
|
32
|
+
const output = new Writable({
|
|
33
|
+
write(chunk, _encoding, callback) {
|
|
34
|
+
if (!muted)
|
|
35
|
+
process.stderr.write(chunk);
|
|
36
|
+
callback();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const terminal = createInterface({ input: process.stdin, output, terminal: true });
|
|
40
|
+
const ask = (question) => terminal.question(question);
|
|
41
|
+
const askHidden = async (question) => {
|
|
42
|
+
process.stderr.write(question);
|
|
43
|
+
muted = true;
|
|
44
|
+
const answer = await terminal.question("");
|
|
45
|
+
muted = false;
|
|
46
|
+
process.stderr.write("\n");
|
|
47
|
+
return answer;
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
const countryCode = parseCountryCode((await ask("Country (NL, DE or FR) [NL]: ")) || "NL");
|
|
51
|
+
const email = (await ask("Email: ")).trim();
|
|
52
|
+
const password = await askHidden("Password: ");
|
|
53
|
+
const client = new PicnicClient({ countryCode });
|
|
54
|
+
const result = await client.auth.login(email, password);
|
|
55
|
+
let authKey = result.authKey;
|
|
56
|
+
if (result.secondFactorAuthenticationRequired) {
|
|
57
|
+
await client.auth.generate2FACode("SMS");
|
|
58
|
+
const code = (await ask("Code from the text message Picnic sent you: ")).trim();
|
|
59
|
+
authKey = (await client.auth.verify2FACode(code)).authKey;
|
|
60
|
+
}
|
|
61
|
+
process.stderr.write("\nSigned in. Set this as PICNIC_AUTH_KEY:\n");
|
|
62
|
+
process.stdout.write(`${authKey}\n`);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
terminal.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function main() {
|
|
69
|
+
const command = process.argv[2];
|
|
70
|
+
if (command === undefined)
|
|
71
|
+
return serve();
|
|
72
|
+
if (command === "login")
|
|
73
|
+
return login();
|
|
74
|
+
console.error(command === "--help" || command === "-h" ? USAGE : `Unknown command "${command}".\n\n${USAGE}`);
|
|
75
|
+
process.exit(command === "--help" || command === "-h" ? 0 : 1);
|
|
76
|
+
}
|
|
77
|
+
main().catch((error) => {
|
|
78
|
+
if (PicnicClient.isPicnicApiError(error) && error.code === "AUTH_INVALID_CRED") {
|
|
79
|
+
console.error("The email or password is wrong.");
|
|
80
|
+
}
|
|
81
|
+
else if (PicnicClient.isPicnicApiError(error) && error.code === "OTP_NOT_VALID") {
|
|
82
|
+
console.error("The code is wrong or expired. Run `picnic-mcp login` again.");
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
console.error(error instanceof Error ? error.message : error);
|
|
86
|
+
}
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|
|
89
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import PicnicClient from "picnic-api";
|
|
2
|
+
/**
|
|
3
|
+
* Turns an error from picnic-api into a short message for the model. Response bodies stay out:
|
|
4
|
+
* they can be long HTML pages or contain account data.
|
|
5
|
+
*/
|
|
6
|
+
export function describeError(error) {
|
|
7
|
+
if (PicnicClient.isPicnicApiError(error)) {
|
|
8
|
+
// Throttling comes from the edge in front of Picnic's API as a 403 with an HTML page.
|
|
9
|
+
if (/<html[\s>]/i.test(error.body)) {
|
|
10
|
+
return "Picnic is rate limiting this client. Try again in a few minutes.";
|
|
11
|
+
}
|
|
12
|
+
if (error.status === 401 || error.status === 403) {
|
|
13
|
+
return "The Picnic auth key is invalid or expired. Get a new one with `npx picnic-mcp login` and update PICNIC_AUTH_KEY.";
|
|
14
|
+
}
|
|
15
|
+
return `Picnic answered with HTTP ${error.status}${error.code ? ` (${error.code})` : ""}.`;
|
|
16
|
+
}
|
|
17
|
+
if (PicnicClient.isUnexpectedPageFormatError(error)) {
|
|
18
|
+
return "Picnic changed the format of a page this server reads. Update picnic-mcp, or report it if this is the latest version.";
|
|
19
|
+
}
|
|
20
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
21
|
+
return "Picnic did not answer in time. Try again.";
|
|
22
|
+
}
|
|
23
|
+
return "Could not reach Picnic. Try again later.";
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,YAAY,CAAC;AAEtC;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,IAAI,YAAY,CAAC,gBAAgB,CAAC,KAAK,CAAC,EAAE,CAAC;QACzC,sFAAsF;QACtF,IAAI,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACnC,OAAO,kEAAkE,CAAC;QAC5E,CAAC;QACD,IAAI,KAAK,CAAC,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACjD,OAAO,kHAAkH,CAAC;QAC5H,CAAC;QACD,OAAO,6BAA6B,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC;IAC7F,CAAC;IACD,IAAI,YAAY,CAAC,2BAA2B,CAAC,KAAK,CAAC,EAAE,CAAC;QACpD,OAAO,uHAAuH,CAAC;IACjI,CAAC;IACD,IAAI,KAAK,YAAY,KAAK,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc,EAAE,CAAC;QAC5D,OAAO,2CAA2C,CAAC;IACrD,CAAC;IACD,OAAO,0CAA0C,CAAC;AACpD,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Cart, CartLineItem, CountryCode } from "../types.js";
|
|
2
|
+
/** A cart line as a tool shows it to a model. Optional fields are left out when empty. */
|
|
3
|
+
export type CartLineOutput = {
|
|
4
|
+
productId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
size?: string;
|
|
7
|
+
quantity: number;
|
|
8
|
+
/** The price of the line, after discounts. */
|
|
9
|
+
price: string;
|
|
10
|
+
labels?: string[];
|
|
11
|
+
/** Why the product can't be delivered, when it can't. */
|
|
12
|
+
unavailable?: string;
|
|
13
|
+
/** Products Picnic offers instead, when the product can't be delivered. */
|
|
14
|
+
replacements?: ReplacementOutput[];
|
|
15
|
+
};
|
|
16
|
+
/** A product Picnic offers instead of one that can't be delivered. */
|
|
17
|
+
export type ReplacementOutput = {
|
|
18
|
+
productId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
size?: string;
|
|
21
|
+
price: string;
|
|
22
|
+
};
|
|
23
|
+
/** The totals of the cart. */
|
|
24
|
+
export type CartSummaryOutput = {
|
|
25
|
+
itemCount: number;
|
|
26
|
+
/** The amount to pay, including deposits and fees. */
|
|
27
|
+
total: string;
|
|
28
|
+
savings?: string;
|
|
29
|
+
deliveryWindow?: string;
|
|
30
|
+
/** The minimum order value of the selected delivery slot. */
|
|
31
|
+
minimumOrder?: string;
|
|
32
|
+
};
|
|
33
|
+
export type CartOutput = CartSummaryOutput & {
|
|
34
|
+
items: CartLineOutput[];
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Builds the model-facing cart from an allow-list of fields. The cart id, its timestamp, images
|
|
38
|
+
* and other fields a model doesn't need stay out, as do fields picnic-api adds later.
|
|
39
|
+
*/
|
|
40
|
+
export declare function formatCart(cart: Cart, countryCode: CountryCode): CartOutput;
|
|
41
|
+
export declare function formatCartLine(item: CartLineItem, countryCode: CountryCode): CartLineOutput;
|
|
42
|
+
/**
|
|
43
|
+
* Says why a cart line can't be delivered and what Picnic offers instead, or nothing when the line
|
|
44
|
+
* can be delivered.
|
|
45
|
+
*/
|
|
46
|
+
export declare function formatUnavailability(item: CartLineItem, countryCode: CountryCode): Pick<CartLineOutput, "unavailable" | "replacements">;
|
|
47
|
+
export declare function formatCartSummary(cart: Cart, countryCode: CountryCode): CartSummaryOutput;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { formatPrice, formatWindow } from "./prices.js";
|
|
2
|
+
/** At most this many replacements per cart line. */
|
|
3
|
+
const MAX_REPLACEMENTS = 3;
|
|
4
|
+
/**
|
|
5
|
+
* Builds the model-facing cart from an allow-list of fields. The cart id, its timestamp, images
|
|
6
|
+
* and other fields a model doesn't need stay out, as do fields picnic-api adds later.
|
|
7
|
+
*/
|
|
8
|
+
export function formatCart(cart, countryCode) {
|
|
9
|
+
return {
|
|
10
|
+
items: cart.items.map((item) => formatCartLine(item, countryCode)),
|
|
11
|
+
...formatCartSummary(cart, countryCode),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function formatCartLine(item, countryCode) {
|
|
15
|
+
const labels = item.labels.map((label) => label.text).filter(Boolean);
|
|
16
|
+
return {
|
|
17
|
+
productId: item.productId,
|
|
18
|
+
name: item.name,
|
|
19
|
+
...(item.unitQuantity && { size: item.unitQuantity }),
|
|
20
|
+
quantity: item.quantity,
|
|
21
|
+
price: formatPrice(item.displayPrice, countryCode),
|
|
22
|
+
...(labels.length > 0 && { labels }),
|
|
23
|
+
...formatUnavailability(item, countryCode),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Says why a cart line can't be delivered and what Picnic offers instead, or nothing when the line
|
|
28
|
+
* can be delivered.
|
|
29
|
+
*/
|
|
30
|
+
export function formatUnavailability(item, countryCode) {
|
|
31
|
+
if (!item.isUnavailable)
|
|
32
|
+
return {};
|
|
33
|
+
const replacements = item.replacements.slice(0, MAX_REPLACEMENTS).map((replacement) => ({
|
|
34
|
+
productId: replacement.id,
|
|
35
|
+
name: replacement.name,
|
|
36
|
+
...(replacement.unitQuantity && { size: replacement.unitQuantity }),
|
|
37
|
+
price: formatPrice(replacement.displayPrice, countryCode),
|
|
38
|
+
}));
|
|
39
|
+
return {
|
|
40
|
+
unavailable: item.unavailableReason ?? "not available",
|
|
41
|
+
...(replacements.length > 0 && { replacements }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function formatCartSummary(cart, countryCode) {
|
|
45
|
+
const savings = cart.promotionSavings + cart.membershipSavings;
|
|
46
|
+
const window = formatWindow(cart.selectedSlot?.windowStart ?? null, cart.selectedSlot?.windowEnd ?? null);
|
|
47
|
+
return {
|
|
48
|
+
itemCount: cart.totalCount,
|
|
49
|
+
total: formatPrice(cart.totalPrice, countryCode),
|
|
50
|
+
...(savings > 0 && { savings: formatPrice(savings, countryCode) }),
|
|
51
|
+
...(window && { deliveryWindow: window }),
|
|
52
|
+
...(cart.minimumOrderValue !== null && {
|
|
53
|
+
minimumOrder: formatPrice(cart.minimumOrderValue, countryCode),
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=cart.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cart.js","sourceRoot":"","sources":["../../src/format/cart.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CountryCode } from "../types.js";
|
|
2
|
+
/** Formats an amount in cents as the country shows prices, e.g. "€ 2,49" for NL. */
|
|
3
|
+
export declare function formatPrice(cents: number, countryCode: CountryCode): string;
|
|
4
|
+
/** Formats a delivery window as e.g. "Thu 23 Oct, 18:00-19:00", or null without a start and end. */
|
|
5
|
+
export declare function formatWindow(start: string | null, end: string | null): string | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const LOCALES = { NL: "nl-NL", DE: "de-DE", FR: "fr-FR" };
|
|
2
|
+
/** Picnic serves every supported country from the Central European time zone. */
|
|
3
|
+
const TIME_ZONE = "Europe/Amsterdam";
|
|
4
|
+
/** Formats an amount in cents as the country shows prices, e.g. "€ 2,49" for NL. */
|
|
5
|
+
export function formatPrice(cents, countryCode) {
|
|
6
|
+
return new Intl.NumberFormat(LOCALES[countryCode], { style: "currency", currency: "EUR" })
|
|
7
|
+
.format(cents / 100)
|
|
8
|
+
.replace(/\s/g, " "); // Intl uses non-breaking spaces
|
|
9
|
+
}
|
|
10
|
+
/** Formats a delivery window as e.g. "Thu 23 Oct, 18:00-19:00", or null without a start and end. */
|
|
11
|
+
export function formatWindow(start, end) {
|
|
12
|
+
if (!start || !end)
|
|
13
|
+
return null;
|
|
14
|
+
const day = new Intl.DateTimeFormat("en-GB", {
|
|
15
|
+
timeZone: TIME_ZONE,
|
|
16
|
+
weekday: "short",
|
|
17
|
+
day: "numeric",
|
|
18
|
+
month: "short",
|
|
19
|
+
}).format(new Date(start));
|
|
20
|
+
const time = new Intl.DateTimeFormat("en-GB", {
|
|
21
|
+
timeZone: TIME_ZONE,
|
|
22
|
+
hour: "2-digit",
|
|
23
|
+
minute: "2-digit",
|
|
24
|
+
});
|
|
25
|
+
return `${day}, ${time.format(new Date(start))}-${time.format(new Date(end))}`;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=prices.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prices.js","sourceRoot":"","sources":["../../src/format/prices.ts"],"names":[],"mappings":"AAEA,MAAM,OAAO,GAAgC,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC;AAEvF,iFAAiF;AACjF,MAAM,SAAS,GAAG,kBAAkB,CAAC;AAErC,oFAAoF;AACpF,MAAM,UAAU,WAAW,CAAC,KAAa,EAAE,WAAwB;IACjE,OAAO,IAAI,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;SACvF,MAAM,CAAC,KAAK,GAAG,GAAG,CAAC;SACnB,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC,gCAAgC;AAC1D,CAAC;AAED,oGAAoG;AACpG,MAAM,UAAU,YAAY,CAAC,KAAoB,EAAE,GAAkB;IACnE,IAAI,CAAC,KAAK,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IAChC,MAAM,GAAG,GAAG,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE;QAC3C,QAAQ,EAAE,SAAS;QACnB,OAAO,EAAE,OAAO;QAChB,GAAG,EAAE,SAAS;QACd,KAAK,EAAE,OAAO;KACf,CAAC,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3B,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE;QAC5C,QAAQ,EAAE,SAAS;QACnB,IAAI,EAAE,SAAS;QACf,MAAM,EAAE,SAAS;KAClB,CAAC,CAAC;IACH,OAAO,GAAG,GAAG,KAAK,IAAI,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;AACjF,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CountryCode, ProductTile } from "../types.js";
|
|
2
|
+
/** A product as a tool shows it to a model. Optional fields are left out when empty. */
|
|
3
|
+
export type ProductOutput = {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
brand?: string;
|
|
7
|
+
size?: string;
|
|
8
|
+
price: string;
|
|
9
|
+
/** The price before a discount. */
|
|
10
|
+
wasPrice?: string;
|
|
11
|
+
unitPrice?: string;
|
|
12
|
+
/** Promotion and other labels, e.g. "1+1 gratis". */
|
|
13
|
+
labels?: string[];
|
|
14
|
+
/** Set when the user bought the product before. */
|
|
15
|
+
boughtBefore?: true;
|
|
16
|
+
unavailable?: true;
|
|
17
|
+
};
|
|
18
|
+
/** A product from the search results, with whether the user bought it before. */
|
|
19
|
+
export type Candidate = {
|
|
20
|
+
tile: ProductTile;
|
|
21
|
+
boughtBefore: boolean;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Builds the model-facing product from an allow-list of fields, so fields picnic-api adds later
|
|
25
|
+
* don't end up in tool output.
|
|
26
|
+
*/
|
|
27
|
+
export declare function formatProduct(candidate: Candidate, countryCode: CountryCode): ProductOutput;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatPrice } from "./prices.js";
|
|
2
|
+
/**
|
|
3
|
+
* Builds the model-facing product from an allow-list of fields, so fields picnic-api adds later
|
|
4
|
+
* don't end up in tool output.
|
|
5
|
+
*/
|
|
6
|
+
export function formatProduct(candidate, countryCode) {
|
|
7
|
+
const { tile } = candidate;
|
|
8
|
+
const labels = tile.labels.map((label) => label.text).filter((text) => !!text);
|
|
9
|
+
return {
|
|
10
|
+
id: tile.id,
|
|
11
|
+
name: tile.namePrefix ? `${tile.namePrefix} ${tile.name}` : tile.name,
|
|
12
|
+
...(tile.brand && { brand: tile.brand }),
|
|
13
|
+
...(tile.unitQuantity && { size: tile.unitQuantity }),
|
|
14
|
+
price: formatPrice(tile.displayPrice, countryCode),
|
|
15
|
+
...(tile.originalPrice !== null &&
|
|
16
|
+
tile.originalPrice > tile.displayPrice && {
|
|
17
|
+
wasPrice: formatPrice(tile.originalPrice, countryCode),
|
|
18
|
+
}),
|
|
19
|
+
...(tile.unitPrice && { unitPrice: tile.unitPrice }),
|
|
20
|
+
...(labels.length > 0 && { labels }),
|
|
21
|
+
...(candidate.boughtBefore && { boughtBefore: true }),
|
|
22
|
+
...(tile.isUnavailable && { unavailable: true }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=products.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"products.js","sourceRoot":"","sources":["../../src/format/products.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAyB1C;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,SAAoB,EAAE,WAAwB;IAC1E,MAAM,EAAE,IAAI,EAAE,GAAG,SAAS,CAAC;IAC3B,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAkB,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC/F,OAAO;QACL,EAAE,EAAE,IAAI,CAAC,EAAE;QACX,IAAI,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI;QACrE,GAAG,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;QACxC,GAAG,CAAC,IAAI,CAAC,YAAY,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,YAAY,EAAE,CAAC;QACrD,KAAK,EAAE,WAAW,CAAC,IAAI,CAAC,YAAY,EAAE,WAAW,CAAC;QAClD,GAAG,CAAC,IAAI,CAAC,aAAa,KAAK,IAAI;YAC7B,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,YAAY,IAAI;YACxC,QAAQ,EAAE,WAAW,CAAC,IAAI,CAAC,aAAa,EAAE,WAAW,CAAC;SACvD,CAAC;QACJ,GAAG,CAAC,IAAI,CAAC,SAAS,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC;QACpD,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;QACpC,GAAG,CAAC,SAAS,CAAC,YAAY,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC;QACrD,GAAG,CAAC,IAAI,CAAC,aAAa,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;KACjD,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAA4B,MAAM,aAAa,CAAC"}
|
package/dist/match.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Candidate } from "./format/products.js";
|
|
2
|
+
import type { SearchResults } from "./types.js";
|
|
3
|
+
export type Match =
|
|
4
|
+
/** One product fits: add it. */
|
|
5
|
+
{
|
|
6
|
+
type: "best";
|
|
7
|
+
best: Candidate;
|
|
8
|
+
others: Candidate[];
|
|
9
|
+
}
|
|
10
|
+
/** Several fit, or none was bought before: ask the user. */
|
|
11
|
+
| {
|
|
12
|
+
type: "choose";
|
|
13
|
+
choices: Candidate[];
|
|
14
|
+
others: Candidate[];
|
|
15
|
+
}
|
|
16
|
+
/** Nothing that can be ordered. */
|
|
17
|
+
| {
|
|
18
|
+
type: "none";
|
|
19
|
+
others: Candidate[];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Lists the products of the search results in display order, once each, marking the ones in the
|
|
23
|
+
* bought-before section. The search has no per-product order count: a tile's `numberOfOrders` is
|
|
24
|
+
* the same on every tile.
|
|
25
|
+
*/
|
|
26
|
+
export declare function listCandidates(results: SearchResults): Candidate[];
|
|
27
|
+
/**
|
|
28
|
+
* Picks what to add from the search results:
|
|
29
|
+
* - one available product bought before: that product;
|
|
30
|
+
* - several bought before: those to choose from, in Picnic's order;
|
|
31
|
+
* - none bought before: the first results to choose from, or the only one if there is one.
|
|
32
|
+
*/
|
|
33
|
+
export declare function matchProducts(candidates: Candidate[]): Match;
|
package/dist/match.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** The section type of "Opnieuw bestellen": the products the user bought before. */
|
|
2
|
+
const BOUGHT_BEFORE_SECTION = "RFY";
|
|
3
|
+
/** At most this many products are offered to choose between. */
|
|
4
|
+
const MAX_CHOICES = 3;
|
|
5
|
+
/**
|
|
6
|
+
* Lists the products of the search results in display order, once each, marking the ones in the
|
|
7
|
+
* bought-before section. The search has no per-product order count: a tile's `numberOfOrders` is
|
|
8
|
+
* the same on every tile.
|
|
9
|
+
*/
|
|
10
|
+
export function listCandidates(results) {
|
|
11
|
+
const candidates = new Map();
|
|
12
|
+
for (const section of results.products?.sections ?? []) {
|
|
13
|
+
const boughtBefore = section.type === BOUGHT_BEFORE_SECTION;
|
|
14
|
+
for (const tile of section.products) {
|
|
15
|
+
const existing = candidates.get(tile.id);
|
|
16
|
+
if (existing)
|
|
17
|
+
existing.boughtBefore ||= boughtBefore;
|
|
18
|
+
else
|
|
19
|
+
candidates.set(tile.id, { tile, boughtBefore });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return [...candidates.values()];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Picks what to add from the search results:
|
|
26
|
+
* - one available product bought before: that product;
|
|
27
|
+
* - several bought before: those to choose from, in Picnic's order;
|
|
28
|
+
* - none bought before: the first results to choose from, or the only one if there is one.
|
|
29
|
+
*/
|
|
30
|
+
export function matchProducts(candidates) {
|
|
31
|
+
const available = candidates.filter((candidate) => !candidate.tile.isUnavailable);
|
|
32
|
+
const bought = available.filter((candidate) => candidate.boughtBefore);
|
|
33
|
+
const pool = bought.length > 0 ? bought : available;
|
|
34
|
+
const without = (picked) => candidates.filter((candidate) => !picked.includes(candidate));
|
|
35
|
+
if (pool.length === 1) {
|
|
36
|
+
return { type: "best", best: pool[0], others: without(pool) };
|
|
37
|
+
}
|
|
38
|
+
if (pool.length > 1) {
|
|
39
|
+
const choices = pool.slice(0, MAX_CHOICES);
|
|
40
|
+
return { type: "choose", choices, others: without(choices) };
|
|
41
|
+
}
|
|
42
|
+
return { type: "none", others: candidates };
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=match.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"match.js","sourceRoot":"","sources":["../src/match.ts"],"names":[],"mappings":"AAGA,oFAAoF;AACpF,MAAM,qBAAqB,GAAG,KAAK,CAAC;AAEpC,gEAAgE;AAChE,MAAM,WAAW,GAAG,CAAC,CAAC;AAUtB;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,OAAsB;IACnD,MAAM,UAAU,GAAG,IAAI,GAAG,EAAqB,CAAC;IAChD,KAAK,MAAM,OAAO,IAAI,OAAO,CAAC,QAAQ,EAAE,QAAQ,IAAI,EAAE,EAAE,CAAC;QACvD,MAAM,YAAY,GAAG,OAAO,CAAC,IAAI,KAAK,qBAAqB,CAAC;QAC5D,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;YACpC,MAAM,QAAQ,GAAG,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACzC,IAAI,QAAQ;gBAAE,QAAQ,CAAC,YAAY,KAAK,YAAY,CAAC;;gBAChD,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC,CAAC;QACvD,CAAC;IACH,CAAC;IACD,OAAO,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC;AAClC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,UAAuB;IACnD,MAAM,SAAS,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IAClF,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,YAAY,CAAC,CAAC;IACvE,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;IACpD,MAAM,OAAO,GAAG,CAAC,MAAmB,EAAE,EAAE,CACtC,UAAU,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC;IAEhE,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;IAChE,CAAC;IACD,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpB,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;QAC3C,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;IAC/D,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;AAC9C,CAAC"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import PicnicClient from "picnic-api";
|
|
3
|
+
import type { CountryCode } from "./types.js";
|
|
4
|
+
export type CreateServerOptions = ({
|
|
5
|
+
/** The Picnic auth key of the account, from `picnic-mcp login`. */
|
|
6
|
+
authKey: string;
|
|
7
|
+
/** The country of the account. Default: "NL". */
|
|
8
|
+
countryCode?: CountryCode;
|
|
9
|
+
} | {
|
|
10
|
+
/** A picnic-api client that is already signed in. */
|
|
11
|
+
client: PicnicClient;
|
|
12
|
+
/** The country of the client's account. Default: "NL". */
|
|
13
|
+
countryCode?: CountryCode;
|
|
14
|
+
}) & {
|
|
15
|
+
/** Called with errors from Picnic before the tool returns a short message. Default: log to stderr. */
|
|
16
|
+
onError?: (error: unknown) => void;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Creates an MCP server with tools to search Picnic's products and change the cart. It reads no
|
|
20
|
+
* environment variables or files, so it can run in-process: connect it to a transport with
|
|
21
|
+
* `server.connect(transport)`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createServer(options: CreateServerOptions): McpServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import PicnicClient from "picnic-api";
|
|
4
|
+
import { registerCartTools } from "./tools/cart.js";
|
|
5
|
+
import { SEARCH_LANGUAGES } from "./tools/context.js";
|
|
6
|
+
import { registerSearchProducts } from "./tools/search-products.js";
|
|
7
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
8
|
+
/**
|
|
9
|
+
* Creates an MCP server with tools to search Picnic's products and change the cart. It reads no
|
|
10
|
+
* environment variables or files, so it can run in-process: connect it to a transport with
|
|
11
|
+
* `server.connect(transport)`.
|
|
12
|
+
*/
|
|
13
|
+
export function createServer(options) {
|
|
14
|
+
const countryCode = options.countryCode ?? "NL";
|
|
15
|
+
const context = {
|
|
16
|
+
client: "client" in options
|
|
17
|
+
? options.client
|
|
18
|
+
: new PicnicClient({ authKey: options.authKey, countryCode }),
|
|
19
|
+
countryCode,
|
|
20
|
+
onError: options.onError ?? ((error) => console.error("[picnic-mcp]", error)),
|
|
21
|
+
};
|
|
22
|
+
const server = new McpServer({ name: "picnic-mcp", version }, {
|
|
23
|
+
capabilities: { tools: {} },
|
|
24
|
+
instructions: "Tools for the user's account at the online supermarket Picnic. " +
|
|
25
|
+
"To add a product: search_products (query in " +
|
|
26
|
+
SEARCH_LANGUAGES[countryCode] +
|
|
27
|
+
"), then add_to_cart with the product id. If the search returns choices, ask the user first. " +
|
|
28
|
+
"These tools can change the cart but cannot place an order.",
|
|
29
|
+
});
|
|
30
|
+
registerSearchProducts(server, context);
|
|
31
|
+
registerCartTools(server, context);
|
|
32
|
+
return server;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,YAAY,MAAM,YAAY,CAAC;AAEtC,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,gBAAgB,EAAoB,MAAM,oBAAoB,CAAC;AACxE,OAAO,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAGpE,MAAM,EAAE,OAAO,EAAE,GAAG,aAAa,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,iBAAiB,CAAwB,CAAC;AAoB7F;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,OAA4B;IACvD,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,IAAI,CAAC;IAChD,MAAM,OAAO,GAAgB;QAC3B,MAAM,EACJ,QAAQ,IAAI,OAAO;YACjB,CAAC,CAAC,OAAO,CAAC,MAAM;YAChB,CAAC,CAAC,IAAI,YAAY,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,WAAW,EAAE,CAAC;QACjE,WAAW;QACX,OAAO,EAAE,OAAO,CAAC,OAAO,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,CAAC;KAC9E,CAAC;IAEF,MAAM,MAAM,GAAG,IAAI,SAAS,CAC1B,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,EAC/B;QACE,YAAY,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE;QAC3B,YAAY,EACV,iEAAiE;YACjE,8CAA8C;YAC9C,gBAAgB,CAAC,WAAW,CAAC;YAC7B,8FAA8F;YAC9F,4DAA4D;KAC/D,CACF,CAAC;IACF,sBAAsB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,iBAAiB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { formatCart, formatCartSummary, formatUnavailability } from "../format/cart.js";
|
|
3
|
+
import { errorResult, jsonResult, runTool } from "./result.js";
|
|
4
|
+
const productId = z
|
|
5
|
+
.string()
|
|
6
|
+
.trim()
|
|
7
|
+
.regex(/^s\d+$/, "Use a product id from search_products or get_cart, e.g. s1004201.")
|
|
8
|
+
.describe("The product id from search_products or get_cart, e.g. s1004201.");
|
|
9
|
+
const quantity = (what) => z.number().int().min(1).max(50).default(1).describe(`How many to ${what}. Default 1.`);
|
|
10
|
+
export function registerCartTools(server, context) {
|
|
11
|
+
const { client, countryCode, onError } = context;
|
|
12
|
+
server.registerTool("get_cart", {
|
|
13
|
+
title: "Get cart",
|
|
14
|
+
description: "Show the user's Picnic shopping cart: the products with their quantity and price, the total, and the delivery window. " +
|
|
15
|
+
"A product that can't be delivered in that window has `unavailable` with the reason, and `replacements` Picnic offers instead.",
|
|
16
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
17
|
+
}, () => runTool(async () => jsonResult(formatCart(await client.cart.getCart(), countryCode)), onError));
|
|
18
|
+
server.registerTool("add_to_cart", {
|
|
19
|
+
title: "Add to cart",
|
|
20
|
+
description: "Add a product to the user's Picnic shopping cart. Get the product id from search_products first. " +
|
|
21
|
+
"Returns the product with the quantity now in the cart, and the cart total. " +
|
|
22
|
+
"If the product can't be delivered in the selected delivery window, it says so and lists `replacements`: tell the user and offer them. " +
|
|
23
|
+
"This does not place an order.",
|
|
24
|
+
inputSchema: z.object({ productId, quantity: quantity("add") }),
|
|
25
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
26
|
+
}, ({ productId, quantity }) => runTool(async () => {
|
|
27
|
+
const cart = await client.cart.addProductToCart(productId, quantity);
|
|
28
|
+
const lines = linesOf(cart, productId);
|
|
29
|
+
if (lines.length === 0) {
|
|
30
|
+
return errorResult(`Picnic did not add ${productId}. Check the product id with search_products.`);
|
|
31
|
+
}
|
|
32
|
+
const unavailableLine = lines.find((line) => line.isUnavailable);
|
|
33
|
+
return jsonResult({
|
|
34
|
+
...(unavailableLine && {
|
|
35
|
+
next: "The product is in the cart, but it can't be delivered in the selected delivery window. Tell the user, and offer the replacements if there are any.",
|
|
36
|
+
}),
|
|
37
|
+
added: {
|
|
38
|
+
productId,
|
|
39
|
+
name: lines[0].name,
|
|
40
|
+
quantityInCart: quantityOf(lines),
|
|
41
|
+
...(unavailableLine && formatUnavailability(unavailableLine, countryCode)),
|
|
42
|
+
},
|
|
43
|
+
cart: formatCartSummary(cart, countryCode),
|
|
44
|
+
});
|
|
45
|
+
}, onError));
|
|
46
|
+
server.registerTool("remove_from_cart", {
|
|
47
|
+
title: "Remove from cart",
|
|
48
|
+
description: "Remove a product from the user's Picnic shopping cart. Get the product id from get_cart. " +
|
|
49
|
+
"Returns the quantity left in the cart and the cart total.",
|
|
50
|
+
inputSchema: z.object({ productId, quantity: quantity("remove") }),
|
|
51
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
52
|
+
}, ({ productId, quantity }) => runTool(async () => {
|
|
53
|
+
const cart = await client.cart.removeProductFromCart(productId, quantity);
|
|
54
|
+
return jsonResult({
|
|
55
|
+
removed: { productId, quantityInCart: quantityOf(linesOf(cart, productId)) },
|
|
56
|
+
cart: formatCartSummary(cart, countryCode),
|
|
57
|
+
});
|
|
58
|
+
}, onError));
|
|
59
|
+
}
|
|
60
|
+
/** The lines of a product: more than one when it was also added for a recipe. */
|
|
61
|
+
function linesOf(cart, productId) {
|
|
62
|
+
return cart.items.filter((item) => item.productId === productId);
|
|
63
|
+
}
|
|
64
|
+
function quantityOf(lines) {
|
|
65
|
+
return lines.reduce((sum, line) => sum + line.quantity, 0);
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=cart.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cart.js","sourceRoot":"","sources":["../../src/tools/cart.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AAEzB,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAGxF,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE/D,MAAM,SAAS,GAAG,CAAC;KAChB,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,QAAQ,EAAE,mEAAmE,CAAC;KACpF,QAAQ,CAAC,iEAAiE,CAAC,CAAC;AAE/E,MAAM,QAAQ,GAAG,CAAC,IAAY,EAAE,EAAE,CAChC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,eAAe,IAAI,cAAc,CAAC,CAAC;AAEzF,MAAM,UAAU,iBAAiB,CAAC,MAAiB,EAAE,OAAoB;IACvE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAEjD,MAAM,CAAC,YAAY,CACjB,UAAU,EACV;QACE,KAAK,EAAE,UAAU;QACjB,WAAW,EACT,wHAAwH;YACxH,+HAA+H;QACjI,WAAW,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE;KACzD,EACD,GAAG,EAAE,CACH,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,WAAW,CAAC,CAAC,EAAE,OAAO,CAAC,CACjG,CAAC;IAEF,MAAM,CAAC,YAAY,CACjB,aAAa,EACb;QACE,KAAK,EAAE,aAAa;QACpB,WAAW,EACT,mGAAmG;YACnG,6EAA6E;YAC7E,wIAAwI;YACxI,+BAA+B;QACjC,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC/D,WAAW,EAAE,EAAE,YAAY,EAAE,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE;KAClF,EACD,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,EAAE,CAC1B,OAAO,CAAC,KAAK,IAAI,EAAE;QACjB,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QACrE,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;QACvC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,OAAO,WAAW,CAChB,sBAAsB,SAAS,8CAA8C,CAC9E,CAAC;QACJ,CAAC;QACD,MAAM,eAAe,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;QACjE,OAAO,UAAU,CAAC;YAChB,GAAG,CAAC,eAAe,IAAI;gBACrB,IAAI,EAAE,oJAAoJ;aAC3J,CAAC;YACF,KAAK,EAAE;gBACL,SAAS;gBACT,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI;gBACnB,cAAc,EAAE,UAAU,CAAC,KAAK,CAAC;gBACjC,GAAG,CAAC,eAAe,IAAI,oBAAoB,CAAC,eAAe,EAAE,WAAW,CAAC,CAAC;aAC3E;YACD,IAAI,EAAE,iBAAiB,CAAC,IAAI,EAAE,WAAW,CAAC;SAC3C,CAAC,CAAC;IACL,CAAC,EAAE,OAAO,CAAC,CACd,CAAC;IAEF,MAAM,CAAC,YAAY,CACjB,kBAAkB,EAClB;QACE,KAAK,EAAE,kBAAkB;QACzB,WAAW,EACT,2FAA2F;YAC3F,2DAA2D;QAC7D,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QAClE,WAAW,EAAE,EAAE,YAAY,EAAE,KAAK,EAAE,eAAe,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE;KACjF,EACD,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,EAAE,CAC1B,OAAO,CAAC,KAAK,IAAI,EAAE;QACjB,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAC1E,OAAO,UAAU,CAAC;YAChB,OAAO,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,UAAU,CAAC,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,EAAE;YAC5E,IAAI,EAAE,iBAAiB,CAAC,IAAI,EAAE,WAAW,CAAC;SAC3C,CAAC,CAAC;IACL,CAAC,EAAE,OAAO,CAAC,CACd,CAAC;AACJ,CAAC;AAED,iFAAiF;AACjF,SAAS,OAAO,CAAC,IAAU,EAAE,SAAiB;IAC5C,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC;AACnE,CAAC;AAED,SAAS,UAAU,CAAC,KAAqB;IACvC,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,GAAG,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;AAC7D,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type PicnicClient from "picnic-api";
|
|
2
|
+
import type { CountryCode } from "../types.js";
|
|
3
|
+
/** What the tools share: the Picnic client and the account's country. */
|
|
4
|
+
export type ToolContext = {
|
|
5
|
+
client: PicnicClient;
|
|
6
|
+
countryCode: CountryCode;
|
|
7
|
+
onError: (error: unknown) => void;
|
|
8
|
+
};
|
|
9
|
+
/** The language Picnic's search expects in each country. */
|
|
10
|
+
export declare const SEARCH_LANGUAGES: Record<CountryCode, string>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.js","sourceRoot":"","sources":["../../src/tools/context.ts"],"names":[],"mappings":"AAWA,4DAA4D;AAC5D,MAAM,CAAC,MAAM,gBAAgB,GAAgC;IAC3D,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,QAAQ;CACb,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
2
|
+
/** Returns data as compact JSON text: models read the text, and indentation only costs tokens. */
|
|
3
|
+
export declare function jsonResult(data: unknown): CallToolResult;
|
|
4
|
+
export declare function errorResult(message: string): CallToolResult;
|
|
5
|
+
/** Runs a tool handler and turns a thrown error into a short tool error. */
|
|
6
|
+
export declare function runTool(run: () => Promise<CallToolResult>, onError: (error: unknown) => void): Promise<CallToolResult>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describeError } from "../errors.js";
|
|
2
|
+
/** Returns data as compact JSON text: models read the text, and indentation only costs tokens. */
|
|
3
|
+
export function jsonResult(data) {
|
|
4
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
5
|
+
}
|
|
6
|
+
export function errorResult(message) {
|
|
7
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
8
|
+
}
|
|
9
|
+
/** Runs a tool handler and turns a thrown error into a short tool error. */
|
|
10
|
+
export async function runTool(run, onError) {
|
|
11
|
+
try {
|
|
12
|
+
return await run();
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
onError(error);
|
|
16
|
+
return errorResult(describeError(error));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=result.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result.js","sourceRoot":"","sources":["../../src/tools/result.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,kGAAkG;AAClG,MAAM,UAAU,UAAU,CAAC,IAAa;IACtC,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;AACrE,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AACvE,CAAC;AAED,4EAA4E;AAC5E,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,GAAkC,EAClC,OAAiC;IAEjC,IAAI,CAAC;QACH,OAAO,MAAM,GAAG,EAAE,CAAC;IACrB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,CAAC;QACf,OAAO,WAAW,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;IAC3C,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { formatProduct } from "../format/products.js";
|
|
3
|
+
import { listCandidates, matchProducts } from "../match.js";
|
|
4
|
+
import { SEARCH_LANGUAGES } from "./context.js";
|
|
5
|
+
import { jsonResult, runTool } from "./result.js";
|
|
6
|
+
/** At most this many products in the output, to keep it short. */
|
|
7
|
+
const MAX_PRODUCTS = 8;
|
|
8
|
+
export function registerSearchProducts(server, context) {
|
|
9
|
+
const language = SEARCH_LANGUAGES[context.countryCode];
|
|
10
|
+
server.registerTool("search_products", {
|
|
11
|
+
title: "Search products",
|
|
12
|
+
description: "Search the Picnic supermarket for a product and get its id for add_to_cart. " +
|
|
13
|
+
`Write the query in ${language}, as a short product name. ` +
|
|
14
|
+
"The result has `bestMatch` when one product fits, usually one the user bought before: add that one. " +
|
|
15
|
+
"It has `choices` when there is no clear match: ask the user which one they want before adding anything. `boughtBefore` marks products the user ordered before.",
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
query: z.string().trim().min(1).describe(`The product to search for, in ${language}.`),
|
|
18
|
+
}),
|
|
19
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
20
|
+
}, ({ query }) => runTool(async () => {
|
|
21
|
+
const results = await context.client.catalog.getSearchResults(query);
|
|
22
|
+
const match = matchProducts(listCandidates(results));
|
|
23
|
+
const format = (candidates) => candidates.map((candidate) => formatProduct(candidate, context.countryCode));
|
|
24
|
+
const corrected = results.correctedQuery && results.correctedQuery !== query
|
|
25
|
+
? { searchedFor: results.correctedQuery }
|
|
26
|
+
: {};
|
|
27
|
+
if (match.type === "best") {
|
|
28
|
+
return jsonResult({
|
|
29
|
+
...corrected,
|
|
30
|
+
next: "Add bestMatch with add_to_cart, unless the user asked for something else.",
|
|
31
|
+
bestMatch: formatProduct(match.best, context.countryCode),
|
|
32
|
+
otherResults: format(match.others.slice(0, MAX_PRODUCTS - 1)),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (match.type === "choose") {
|
|
36
|
+
return jsonResult({
|
|
37
|
+
...corrected,
|
|
38
|
+
next: "No clear match. Ask the user which of the choices they want, then add it with add_to_cart.",
|
|
39
|
+
choices: format(match.choices),
|
|
40
|
+
otherResults: format(match.others.slice(0, MAX_PRODUCTS - match.choices.length)),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return jsonResult({
|
|
44
|
+
...corrected,
|
|
45
|
+
next: match.others.length > 0
|
|
46
|
+
? "None of the results can be ordered right now. Tell the user, or try another search term."
|
|
47
|
+
: "Nothing found. Try another search term, or tell the user.",
|
|
48
|
+
otherResults: format(match.others.slice(0, MAX_PRODUCTS)),
|
|
49
|
+
});
|
|
50
|
+
}, context.onError));
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=search-products.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-products.js","sourceRoot":"","sources":["../../src/tools/search-products.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AAEzB,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEtD,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAoB,MAAM,cAAc,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAElD,kEAAkE;AAClE,MAAM,YAAY,GAAG,CAAC,CAAC;AAEvB,MAAM,UAAU,sBAAsB,CAAC,MAAiB,EAAE,OAAoB;IAC5E,MAAM,QAAQ,GAAG,gBAAgB,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACvD,MAAM,CAAC,YAAY,CACjB,iBAAiB,EACjB;QACE,KAAK,EAAE,iBAAiB;QACxB,WAAW,EACT,8EAA8E;YAC9E,sBAAsB,QAAQ,6BAA6B;YAC3D,sGAAsG;YACtG,gKAAgK;QAClK,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC;YACpB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,iCAAiC,QAAQ,GAAG,CAAC;SACvF,CAAC;QACF,WAAW,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE;KACzD,EACD,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CACZ,OAAO,CAAC,KAAK,IAAI,EAAE;QACjB,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC;QACrE,MAAM,KAAK,GAAG,aAAa,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC;QACrD,MAAM,MAAM,GAAG,CAAC,UAAuB,EAAE,EAAE,CACzC,UAAU,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC;QAC/E,MAAM,SAAS,GACb,OAAO,CAAC,cAAc,IAAI,OAAO,CAAC,cAAc,KAAK,KAAK;YACxD,CAAC,CAAC,EAAE,WAAW,EAAE,OAAO,CAAC,cAAc,EAAE;YACzC,CAAC,CAAC,EAAE,CAAC;QAET,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;YAC1B,OAAO,UAAU,CAAC;gBAChB,GAAG,SAAS;gBACZ,IAAI,EAAE,2EAA2E;gBACjF,SAAS,EAAE,aAAa,CAAC,KAAK,CAAC,IAAI,EAAE,OAAO,CAAC,WAAW,CAAC;gBACzD,YAAY,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,GAAG,CAAC,CAAC,CAAC;aAC9D,CAAC,CAAC;QACL,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC5B,OAAO,UAAU,CAAC;gBAChB,GAAG,SAAS;gBACZ,IAAI,EAAE,4FAA4F;gBAClG,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC;gBAC9B,YAAY,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;aACjF,CAAC,CAAC;QACL,CAAC;QACD,OAAO,UAAU,CAAC;YAChB,GAAG,SAAS;YACZ,IAAI,EACF,KAAK,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC;gBACrB,CAAC,CAAC,0FAA0F;gBAC5F,CAAC,CAAC,2DAA2D;YACjE,YAAY,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;SAC1D,CAAC,CAAC;IACL,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,CACtB,CAAC;AACJ,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import PicnicClient from "picnic-api";
|
|
2
|
+
export type CountryCode = "NL" | "DE" | "FR";
|
|
3
|
+
export declare const COUNTRY_CODES: readonly CountryCode[];
|
|
4
|
+
export type SearchResults = PicnicClient.Types.catalog.SearchResults;
|
|
5
|
+
export type ProductTile = PicnicClient.Types.catalog.ProductTile;
|
|
6
|
+
export type Cart = PicnicClient.Types.cart.Cart;
|
|
7
|
+
export type CartLineItem = PicnicClient.Types.cart.CartLineItem;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,aAAa,GAA2B,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "picnic-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for the online supermarket Picnic: search products and manage the cart",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"picnic",
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"supermarket",
|
|
10
|
+
"groceries"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/MRVDH/picnic-mcp",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/MRVDH/picnic-mcp/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/MRVDH/picnic-mcp.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Maarten van den Hoven <info@maartenvandenhoven.com> (https://maartenvandenhoven.com)",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist/**"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"picnic-mcp": "dist/cli.js"
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"prebuild": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
42
|
+
"build": "tsc -p tsconfig.build.json",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"format": "prettier --write .",
|
|
46
|
+
"format:check": "prettier --check ."
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/server": "^2.3.1",
|
|
50
|
+
"picnic-api": "^5.0.0",
|
|
51
|
+
"zod": "^4.6.7"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@modelcontextprotocol/client": "^2.3.1",
|
|
55
|
+
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
|
56
|
+
"@types/node": "^26.6.5",
|
|
57
|
+
"prettier": "^3.9.10",
|
|
58
|
+
"typescript": "^7.0.2",
|
|
59
|
+
"vitest": "^5.0.5"
|
|
60
|
+
}
|
|
61
|
+
}
|