continente-mcp 3.1.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.
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ continente-cookie-reader — export Continente.pt cookies from your browser.
4
+
5
+ Reads browser cookies for continente.pt and writes them to ~/.continente/cookies.json
6
+ so the MCP server can use them. Auto-detects your browser or let you specify one.
7
+
8
+ Usage:
9
+ python3 continente-cookie-reader.py # auto-detect
10
+ python3 continente-cookie-reader.py --browser chrome
11
+ python3 continente-cookie-reader.py --browser firefox
12
+ python3 continente-cookie-reader.py --output /path/to/cookies.json
13
+ python3 continente-cookie-reader.py --list-browsers
14
+
15
+ Requirements:
16
+ pip install browser-cookie3
17
+
18
+ Environment variables:
19
+ CONTINENTE_COOKIES_PATH Override the default output path (~/.continente/cookies.json)
20
+ CONTINENTE_VAULT_COOKIE_PATH If set, also write cookies to this path (useful for syncing
21
+ to a second machine via a shared folder, e.g. Obsidian vault)
22
+ """
23
+ import argparse
24
+ import json
25
+ import os
26
+ import stat
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ DOMAIN = "continente.pt"
31
+ DEFAULT_OUTPUT = Path(
32
+ os.environ.get("CONTINENTE_COOKIES_PATH", str(Path.home() / ".continente" / "cookies.json"))
33
+ )
34
+
35
+ # If set, cookies are also written here (e.g. for syncing to a remote machine)
36
+ VAULT_PATH = Path(os.environ["CONTINENTE_VAULT_COOKIE_PATH"]) if "CONTINENTE_VAULT_COOKIE_PATH" in os.environ else None
37
+
38
+ # Tried in this order when auto-detecting
39
+ BROWSERS = ["arc", "chrome", "edge", "brave", "firefox", "chromium", "vivaldi", "opera", "safari"]
40
+
41
+
42
+ def get_browser_cookies(browser_name: str) -> list[dict]:
43
+ """Read continente.pt cookies from the given browser."""
44
+ import browser_cookie3
45
+
46
+ getter = getattr(browser_cookie3, browser_name, None)
47
+ if getter is None:
48
+ raise ValueError(f"Browser '{browser_name}' not supported by browser-cookie3.")
49
+
50
+ jar = getter(domain_name=DOMAIN)
51
+ cookies = []
52
+ for c in jar:
53
+ cookies.append({
54
+ "domain": c.domain,
55
+ "name": c.name,
56
+ "value": c.value,
57
+ "path": getattr(c, "path", "/"),
58
+ "secure": bool(getattr(c, "secure", False)),
59
+ "httpOnly": c.has_nonstandard_attr("HttpOnly") if hasattr(c, "has_nonstandard_attr") else False,
60
+ "sameSite": "Lax",
61
+ "expires": getattr(c, "expires", None),
62
+ })
63
+ return cookies
64
+
65
+
66
+ def find_cookies(browser: str | None) -> tuple[list[dict], str]:
67
+ """Return (cookies, browser_name). Tries specified browser or auto-detects."""
68
+ if browser:
69
+ try:
70
+ cookies = get_browser_cookies(browser)
71
+ except Exception as e:
72
+ print(f"Error reading {browser}: {e}", file=sys.stderr)
73
+ sys.exit(1)
74
+ if not cookies:
75
+ print(f"No Continente cookies found in {browser}. Are you logged in?", file=sys.stderr)
76
+ sys.exit(1)
77
+ return cookies, browser
78
+
79
+ print("Auto-detecting browser with Continente cookies...", file=sys.stderr)
80
+ for name in BROWSERS:
81
+ try:
82
+ cookies = get_browser_cookies(name)
83
+ if cookies:
84
+ return cookies, name
85
+ except Exception as e:
86
+ print(f" {name}: {e}", file=sys.stderr)
87
+ continue
88
+
89
+ print(
90
+ "\nNo Continente cookies found in any supported browser.\n"
91
+ "Make sure you are logged into continente.pt, then try again.\n"
92
+ f"Supported browsers: {', '.join(BROWSERS)}",
93
+ file=sys.stderr,
94
+ )
95
+ sys.exit(1)
96
+
97
+
98
+ def write_cookie_file(cookies: list[dict], path: Path) -> None:
99
+ """Write cookies JSON and set owner-only permissions."""
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ path.write_text(json.dumps(cookies, indent=2, ensure_ascii=False))
102
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 600 — cookies = full account access
103
+
104
+
105
+ def main() -> None:
106
+ parser = argparse.ArgumentParser(
107
+ description="Export Continente.pt browser cookies for use with continente-mcp.",
108
+ formatter_class=argparse.RawDescriptionHelpFormatter,
109
+ )
110
+ parser.add_argument(
111
+ "--browser", "-b",
112
+ choices=BROWSERS,
113
+ metavar="BROWSER",
114
+ help=f"Browser to read from (default: auto-detect). One of: {', '.join(BROWSERS)}",
115
+ )
116
+ parser.add_argument(
117
+ "--output", "-o",
118
+ type=Path,
119
+ default=DEFAULT_OUTPUT,
120
+ help=f"Output path (default: {DEFAULT_OUTPUT})",
121
+ )
122
+ parser.add_argument(
123
+ "--list-browsers",
124
+ action="store_true",
125
+ help="List supported browsers and exit",
126
+ )
127
+ args = parser.parse_args()
128
+
129
+ if args.list_browsers:
130
+ print("Supported browsers: " + ", ".join(BROWSERS))
131
+ sys.exit(0)
132
+
133
+ try:
134
+ import browser_cookie3 # noqa: F401
135
+ except ImportError:
136
+ print(
137
+ "browser-cookie3 is not installed.\n"
138
+ "Run: pip install browser-cookie3",
139
+ file=sys.stderr,
140
+ )
141
+ sys.exit(1)
142
+
143
+ cookies, browser_name = find_cookies(args.browser)
144
+
145
+ write_cookie_file(cookies, args.output)
146
+ print(f"Saved {len(cookies)} cookies → {args.output} (browser: {browser_name})")
147
+
148
+ # Optional: also write to a secondary path (e.g. for syncing to a remote machine)
149
+ if VAULT_PATH:
150
+ existing = []
151
+ if VAULT_PATH.exists():
152
+ try:
153
+ existing = json.loads(VAULT_PATH.read_text())
154
+ except Exception:
155
+ pass
156
+ if cookies != existing:
157
+ write_cookie_file(cookies, VAULT_PATH)
158
+ print(f"Saved {len(cookies)} cookies → {VAULT_PATH} (vault sync)")
159
+ else:
160
+ print(f"Vault unchanged — skipping write to {VAULT_PATH}")
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Continente Session Keepalive
4
+
5
+ Pings continente.pt to reset the idle session timeout.
6
+ If the session has expired, re-reads cookies from your browser automatically.
7
+
8
+ Run every 20 minutes via cron or launchd:
9
+ */20 * * * * python3 /path/to/continente-keepalive.py >> ~/.continente/keepalive.log 2>&1
10
+ """
11
+ import json
12
+ import os
13
+ import stat
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import requests
19
+
20
+ LOCAL_COOKIE = Path(
21
+ os.environ.get("CONTINENTE_COOKIES_PATH", str(Path.home() / ".continente" / "cookies.json"))
22
+ )
23
+ PING_URL = "https://www.continente.pt/on/demandware.store/Sites-continente-Site/default/Account-Show"
24
+ CHECK_URL = "https://www.continente.pt/conta/encomendas/"
25
+ LOG_PREFIX = "[continente-keepalive]"
26
+
27
+ USER_AGENT = (
28
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
29
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
30
+ )
31
+
32
+
33
+ def log(msg: str) -> None:
34
+ print(f"{LOG_PREFIX} {msg}", flush=True)
35
+
36
+
37
+ def load_cookies(path: Path) -> list[dict] | None:
38
+ if not path.exists():
39
+ return None
40
+ try:
41
+ data = json.loads(path.read_text())
42
+ return data if isinstance(data, list) else data.get("cookies")
43
+ except Exception as e:
44
+ log(f"Failed to read {path}: {e}")
45
+ return None
46
+
47
+
48
+ def save_cookies(cookies: list[dict]) -> None:
49
+ LOCAL_COOKIE.parent.mkdir(parents=True, exist_ok=True)
50
+ LOCAL_COOKIE.write_text(json.dumps(cookies, indent=2, ensure_ascii=False))
51
+ os.chmod(LOCAL_COOKIE, stat.S_IRUSR | stat.S_IWUSR)
52
+
53
+
54
+ def is_logged_in(session: requests.Session) -> bool:
55
+ try:
56
+ r = session.get(CHECK_URL, timeout=10, allow_redirects=True)
57
+ return "login" not in r.url and r.status_code == 200
58
+ except Exception:
59
+ return False
60
+
61
+
62
+ def refresh_from_browser() -> list[dict] | None:
63
+ """Re-run continente-cookie-reader.py to pull fresh cookies from the browser."""
64
+ script = Path(__file__).parent / "continente-cookie-reader.py"
65
+ if not script.exists():
66
+ log("continente-cookie-reader.py not found next to this script.")
67
+ return None
68
+ try:
69
+ result = subprocess.run(
70
+ [sys.executable, str(script)],
71
+ capture_output=True, text=True, timeout=30
72
+ )
73
+ output = (result.stdout + result.stderr).strip()
74
+ if output:
75
+ log(f"Cookie reader: {output}")
76
+ return load_cookies(LOCAL_COOKIE)
77
+ except Exception as e:
78
+ log(f"Cookie reader failed: {e}")
79
+ return None
80
+
81
+
82
+ def ping(cookies: list[dict]) -> bool:
83
+ session = requests.Session()
84
+ session.headers.update({
85
+ "User-Agent": USER_AGENT,
86
+ "Accept-Language": "pt-PT,pt;q=0.9",
87
+ })
88
+ for c in cookies:
89
+ if c.get("name") and c["name"] != "undefined":
90
+ session.cookies.set(c["name"], c["value"], domain=c.get("domain", ".continente.pt"))
91
+
92
+ logged_in = is_logged_in(session)
93
+ if logged_in:
94
+ try:
95
+ session.get(PING_URL, timeout=10)
96
+ except Exception:
97
+ pass
98
+ return logged_in
99
+
100
+
101
+ def main() -> None:
102
+ cookies = load_cookies(LOCAL_COOKIE)
103
+
104
+ if not cookies:
105
+ log("No cookies found — trying to refresh from browser...")
106
+ cookies = refresh_from_browser()
107
+ if not cookies:
108
+ log("ERROR: No cookies found. Run continente-cookie-reader.py first.")
109
+ sys.exit(1)
110
+
111
+ if ping(cookies):
112
+ log("Session alive.")
113
+ save_cookies(cookies)
114
+ return
115
+
116
+ log("Session expired — refreshing from browser...")
117
+ cookies = refresh_from_browser()
118
+ if not cookies:
119
+ log("ERROR: Could not refresh cookies. Run continente-cookie-reader.py manually.")
120
+ sys.exit(1)
121
+
122
+ if ping(cookies):
123
+ save_cookies(cookies)
124
+ log("Session restored.")
125
+ return
126
+
127
+ log(
128
+ "ERROR: Still not logged in after refresh. "
129
+ "Log into Continente in your browser and re-run continente-cookie-reader.py."
130
+ )
131
+ sys.exit(1)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "continente-mcp",
3
+ "version": "3.1.0",
4
+ "description": "MCP server for Continente.pt — search products, manage your cart, and browse order history from your AI assistant.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "continente-mcp": "src/index.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=18.0.0"
12
+ },
13
+ "scripts": {
14
+ "start": "node src/index.js",
15
+ "setup": "playwright install chromium --with-deps",
16
+ "test": "echo \"No tests yet\" && exit 0"
17
+ },
18
+ "keywords": [
19
+ "mcp",
20
+ "model-context-protocol",
21
+ "continente",
22
+ "supermarket",
23
+ "portugal",
24
+ "claude",
25
+ "ai-assistant",
26
+ "shopping",
27
+ "cart"
28
+ ],
29
+ "author": "David Marques",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/davidmarqu3s/continente-mcp.git"
34
+ },
35
+ "homepage": "https://github.com/davidmarqu3s/continente-mcp",
36
+ "bugs": {
37
+ "url": "https://github.com/davidmarqu3s/continente-mcp/issues"
38
+ },
39
+ "files": [
40
+ "src/",
41
+ "skills/",
42
+ "continente-cookie-reader.py",
43
+ "continente-keepalive.py",
44
+ "continente-backup.js",
45
+ "check-change.js",
46
+ ".env.example",
47
+ "LICENSE",
48
+ "README.md"
49
+ ],
50
+ "dependencies": {
51
+ "@modelcontextprotocol/sdk": "^1.29.0",
52
+ "cheerio": "^1.2.0",
53
+ "playwright": "^1.59.1"
54
+ }
55
+ }
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: groceries
3
+ description: Adds items to the Continente online shopping basket. Use this skill whenever the user says /groceries, wants to add something to their Continente cart, mentions shopping for groceries, or asks to "add X to the basket/cart". Also trigger for multi-item lists like "add milk, cheese and eggs to Continente". Works by matching item names against the user's favourites and order history before searching, so the right product variant gets added.
4
+ ---
5
+
6
+ # Groceries Skill
7
+
8
+ Add one or more items to the Continente shopping basket using the `continente-mcp` tools. Match against the user's purchase history to pick the right product variant.
9
+
10
+ ## Input parsing
11
+
12
+ Items may come in various formats:
13
+ - Single item: `add queijo flamengo to the cart`
14
+ - Comma-separated: `/groceries leite, ovos, pão de forma`
15
+ - Natural language: `I need some iogurte and manteiga from Continente`
16
+
17
+ Parse all items before starting. If the input is ambiguous, ask to clarify.
18
+
19
+ ## Matching strategy (per item)
20
+
21
+ The goal is to pick the product the user actually buys, not just any product with that name.
22
+
23
+ **1. Search with favourites context**
24
+
25
+ Call `search_products` with the item name. Results are automatically ranked by the favourites list — the top result is usually correct. Note the top 3.
26
+
27
+ **2. Cross-check favourites**
28
+
29
+ Call `get_favorites` once (reuse for all items in this request). If any favourite matches the item name, prefer it — even if not the top search result. A favourite is a stronger signal than search rank.
30
+
31
+ **3. Fallback: order history**
32
+
33
+ Only call `get_order_history` if the result is genuinely uncertain (e.g. several near-identical variants with different sizes/brands and no favourites match). Scan recent orders to break the tie.
34
+
35
+ **4. Pick**
36
+
37
+ If confidence is high (clear favourite or obvious top result), add silently and report afterwards. If genuinely ambiguous between two equally plausible products, show the top 2 and ask the user to pick.
38
+
39
+ ## Adding to cart
40
+
41
+ Call `add_to_cart` with `product_id`. Default quantity is 1 unless specified (e.g. "2 pacotes de leite").
42
+
43
+ ## Output format
44
+
45
+ After all items are processed, print a compact summary:
46
+
47
+ ```
48
+ ✓ Queijo Flamengo Mil Vacas 400g — added (from favourites)
49
+ ✓ Leite Mimosa Meio-Gordo 1L — added
50
+ ✗ Pão de Forma — unclear match, please check manually
51
+ ```
52
+
53
+ ## Error handling
54
+
55
+ - No search results → report as not found, skip, continue with others
56
+ - `add_to_cart` fails → report the error, move on
57
+ - Never stop the whole run because one item failed
58
+
59
+ ## Performance
60
+
61
+ `get_favorites` is slow. Call it once and reuse across all items. Only call `get_order_history` when favourites give no clear signal.
package/src/auth.js ADDED
@@ -0,0 +1,180 @@
1
+ import { chromium } from 'playwright';
2
+
3
+ const CONTINENTE_LOGIN_URL = 'https://login.continente.pt/u/login?clientId=NLR6WHyO8Iba4eRS&lang=pt-PT';
4
+ const CONTINENTE_MAIN = 'https://www.continente.pt';
5
+ const SESSION_DIR = `${process.env.HOME}/.continente`;
6
+
7
+ export class Authenticator {
8
+ constructor() {
9
+ this.browser = null;
10
+ this.context = null;
11
+ this.page = null;
12
+ this.cookies = [];
13
+ }
14
+
15
+ async ensureBrowser() {
16
+ if (!this.browser) {
17
+ this.browser = await chromium.launch({ headless: true });
18
+ }
19
+ if (!this.context) {
20
+ this.context = await this.browser.newContext({
21
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
22
+ locale: 'pt-PT'
23
+ });
24
+ // Load existing cookies if available
25
+ await this.loadCookies();
26
+ }
27
+ if (!this.page) {
28
+ this.page = await this.context.newPage();
29
+ }
30
+ return this.page;
31
+ }
32
+
33
+ getCookiesPath() {
34
+ return `${SESSION_DIR}/session.json`;
35
+ }
36
+
37
+ async saveCookies() {
38
+ const fs = await import('fs');
39
+ try {
40
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
41
+ } catch (e) {}
42
+ const cookies = await this.context.cookies();
43
+ fs.writeFileSync(this.getCookiesPath(), JSON.stringify(cookies, null, 2));
44
+ }
45
+
46
+ async loadCookies() {
47
+ const fs = await import('fs');
48
+ try {
49
+ const data = fs.readFileSync(this.getCookiesPath(), 'utf8');
50
+ const cookies = JSON.parse(data);
51
+ if (cookies.length > 0) {
52
+ await this.context.addCookies(cookies);
53
+ this.cookies = cookies;
54
+ }
55
+ } catch (e) {
56
+ // No cookies yet
57
+ }
58
+ }
59
+
60
+ async isLoggedIn() {
61
+ try {
62
+ const page = await this.ensureBrowser();
63
+ await page.goto(CONTINENTE_MAIN, { waitUntil: 'domcontentloaded', timeout: 15000 });
64
+ // Look for the account/logged-in state
65
+ const loginLink = await page.$('a[href*="logout"], a[href*="sair"], [class*="user"], [class*="account"]');
66
+ const pageContent = await page.content();
67
+ // If we see a logged-in indicator (e.g., user name in header)
68
+ const isLogged = pageContent.includes('data-testid="user"') ||
69
+ pageContent.includes('class="logged-in') ||
70
+ pageContent.includes('bjzIjUWquR'); // some session marker
71
+ return false; // Conservative - always try to re-auth for now
72
+ } catch (e) {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ async login(email, password) {
78
+ const fs = await import('fs');
79
+ try { fs.mkdirSync(SESSION_DIR, { recursive: true }); } catch (e) {}
80
+
81
+ const page = await this.ensureBrowser();
82
+
83
+ console.error(`[Auth] Starting login flow for ${email}`);
84
+
85
+ // Go to login page
86
+ await page.goto(CONTINENTE_LOGIN_URL, { waitUntil: 'networkidle', timeout: 30000 });
87
+ await page.waitForTimeout(2000);
88
+
89
+ // Step 1: Enter email
90
+ console.error('[Auth] Step 1: Entering email...');
91
+ const emailInput = await page.waitForSelector('input[type="email"], input[type="text"]', { timeout: 10000 }).catch(() => null);
92
+ if (!emailInput) {
93
+ throw new Error('Could not find email input field');
94
+ }
95
+
96
+ await emailInput.fill(email);
97
+ await page.waitForTimeout(500);
98
+
99
+ // Click continue/next
100
+ const continueBtn = await page.waitForSelector('button[type="submit"], button:has-text("Avançar"), button:has-text("Continuar")', { timeout: 5000 }).catch(() => null);
101
+ if (continueBtn) {
102
+ await continueBtn.click();
103
+ } else {
104
+ await page.keyboard.press('Enter');
105
+ }
106
+
107
+ await page.waitForTimeout(2000);
108
+
109
+ // Step 2: Enter password
110
+ console.error('[Auth] Step 2: Entering password...');
111
+ const passwordInput = await page.waitForSelector('input[type="password"]', { timeout: 10000 }).catch(() => null);
112
+ if (!passwordInput) {
113
+ // Maybe already logged in or different flow
114
+ const currentUrl = page.url();
115
+ console.error(`[Auth] No password field found. Current URL: ${currentUrl}`);
116
+ throw new Error('Password field not found - login flow may have changed');
117
+ }
118
+
119
+ await passwordInput.fill(password);
120
+ await page.waitForTimeout(500);
121
+
122
+ // Submit
123
+ const submitBtn = await page.waitForSelector('button[type="submit"], button:has-text("Entrar"), button:has-text("Login")', { timeout: 5000 }).catch(() => null);
124
+ if (submitBtn) {
125
+ await submitBtn.click();
126
+ } else {
127
+ await page.keyboard.press('Enter');
128
+ }
129
+
130
+ // Wait for redirect to main site
131
+ await page.waitForTimeout(3000);
132
+ await page.waitForURL(/continente\.pt/, { timeout: 15000 }).catch(() => {});
133
+
134
+ const finalUrl = page.url();
135
+ console.error(`[Auth] Final URL after login: ${finalUrl}`);
136
+
137
+ // Save session cookies
138
+ await this.saveCookies();
139
+ this.cookies = await this.context.cookies();
140
+
141
+ console.error(`[Auth] Login successful, ${this.cookies.length} cookies saved`);
142
+
143
+ return { success: true, url: finalUrl, cookies: this.cookies.length };
144
+ }
145
+
146
+ async logout() {
147
+ const fs = await import('fs');
148
+ if (this.page) {
149
+ try {
150
+ await this.page.goto(`${CONTINENTE_MAIN}/logout`, { timeout: 5000 }).catch(() => {});
151
+ } catch (e) {}
152
+ await this.page.close();
153
+ this.page = null;
154
+ }
155
+ if (this.context) {
156
+ await this.context.close();
157
+ this.context = null;
158
+ }
159
+ if (this.browser) {
160
+ await this.browser.close();
161
+ this.browser = null;
162
+ }
163
+ // Remove saved cookies
164
+ try { fs.unlinkSync(this.getCookiesPath()); } catch (e) {}
165
+ console.error('[Auth] Logged out, session cleared');
166
+ }
167
+
168
+ async getSessionCookies() {
169
+ if (!this.context && !this.browser) {
170
+ await this.ensureBrowser();
171
+ }
172
+ return this.context ? await this.context.cookies() : [];
173
+ }
174
+
175
+ async close() {
176
+ if (this.page) { try { await this.page.close(); } catch (e) {} this.page = null; }
177
+ if (this.context) { try { await this.context.close(); } catch (e) {} this.context = null; }
178
+ if (this.browser) { try { await this.browser.close(); } catch (e) {} this.browser = null; }
179
+ }
180
+ }