continentept-mcp 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -9,6 +9,9 @@ CONTINENTE_PASSWORD=replace-with-your-password
9
9
  # Close the browser after this many idle seconds; 0 keeps it open. Default: 300
10
10
  # CONTINENTE_BROWSER_IDLE_SECONDS=300
11
11
 
12
+ # Set to 0 to stop the daily check for a newer release on npm.
13
+ # CONTINENTE_UPDATE_CHECK=1
14
+
12
15
  # These two only work as environment variables in the MCP client configuration:
13
16
  # CONTINENTE_CREDENTIALS_FILE=/private/path/credentials.env
14
17
  # CONTINENTE_DATA_DIR=/private/path/continentept-mcp
package/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-10-15
10
+
11
+ ### Added
12
+
13
+ - Update notice: once a day the server checks npm for a newer release and, if there is one, tells the assistant and reports it in `get_session_status`. Offline-safe; turn it off with `CONTINENTE_UPDATE_CHECK=0`.
14
+
15
+ ### Fixed
16
+
17
+ - Documentation: how the home folder is chosen on Windows, what "readable only by your user" covers, when the favorites snapshot is refetched, and what deleting the data directory removes.
18
+
19
+ ### Security
20
+
21
+ - A failed sign-in could write the account password or session cookies to the server log, because browser automation errors include what was typed and the request headers. Sign-in errors are now reported as fixed messages naming only the failed step.
22
+
9
23
  ## [0.3.1] - 2026-10-15
10
24
 
11
25
  ### Added
@@ -41,7 +55,8 @@ All notable changes to this project are documented here. The format follows
41
55
  - Automatic sign-in from environment variables or a private credentials file, and a `login` command.
42
56
  - The `basket-planner` skill for planning a shopping list and updating the basket.
43
57
 
44
- [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...HEAD
58
+ [Unreleased]: https://github.com/andredelgado/continente-mcp/compare/v0.4.0...HEAD
59
+ [0.4.0]: https://github.com/andredelgado/continente-mcp/compare/v0.3.1...v0.4.0
45
60
  [0.3.1]: https://github.com/andredelgado/continente-mcp/compare/v0.3.0...v0.3.1
46
61
  [0.3.0]: https://github.com/andredelgado/continente-mcp/compare/v0.2.0...v0.3.0
47
62
  [0.2.0]: https://github.com/andredelgado/continente-mcp/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -22,7 +22,8 @@ on the website.
22
22
  cannot be confirmed, the result says so and tells the assistant to check the cart instead
23
23
  of retrying.
24
24
  - **Your account**: favorites, order history and a ranking of the products you buy most.
25
- - **Runs locally** over stdio. Credentials and cookies stay on your machine.
25
+ - **Runs locally** over stdio. Your credentials and session are stored on your computer and
26
+ sent only to Continente.pt.
26
27
  - **Built-in guidance.** The server tells the assistant the cart rules as soon as it
27
28
  connects, and offers a `basket-planner` prompt with the full shopping workflow. Nothing
28
29
  extra to install; the same guide also ships as a [skill](skills/basket-planner/SKILL.md).
@@ -44,11 +45,22 @@ it needs it (about 150 MB, once).
44
45
  3. Enter your Continente email and password when asked. Claude Desktop keeps the password
45
46
  in its secure settings storage.
46
47
 
47
- That's it. The extension updates when you install a newer `.mcpb`. The server is also listed
48
+ That's it. The server is also listed
48
49
  in the [MCP Registry](https://registry.modelcontextprotocol.io) as
49
50
  `io.github.andredelgado/continente-mcp`, so clients that browse the registry can find it. To use 1Password instead
50
51
  of storing the password in Claude Desktop, use the manual setup below.
51
52
 
53
+ ### Updates
54
+
55
+ The server checks npm once a day for a newer release. When there is one, it tells the
56
+ assistant, so Claude can mention it, and `get_session_status` shows it.
57
+
58
+ - **Extension:** download the new `continente-mcp.mcpb` and open it. Claude Desktop updates the
59
+ extension in place and keeps your email and password.
60
+ - **npx setups:** using `continentept-mcp@latest` picks up new versions when the client restarts.
61
+ - **Release emails:** on [GitHub](https://github.com/andredelgado/continente-mcp), choose
62
+ **Watch → Custom → Releases**.
63
+
52
64
  ## Manual setup (any MCP client)
53
65
 
54
66
  **1. Save your credentials** in `~/.continentept-mcp/credentials.env` (see
package/dist/cli.js CHANGED
@@ -5,6 +5,9 @@ import { createShoppingApplication } from './runtime.js';
5
5
  import { runAutoLogin } from './auto-login.js';
6
6
  import { isMainModule } from './main-module.js';
7
7
  import { runChromiumInstall } from './session/chromium.js';
8
+ import { safeLoginMessage } from './session/login-errors.js';
9
+ import { checkForUpdate } from './services/updates.js';
10
+ import { loadSettings } from './config.js';
8
11
  const USAGE = `Usage: continentept-mcp [command]
9
12
 
10
13
  Commands:
@@ -17,16 +20,20 @@ Options:
17
20
  -h, --help Show this help
18
21
  -v, --version Show the version
19
22
  `;
20
- function packageVersion() {
23
+ function packageMetadata() {
21
24
  const metadata = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
22
- if (typeof metadata !== 'object' || metadata === null || !('version' in metadata) ||
23
- typeof metadata.version !== 'string')
24
- throw new Error('Missing package version');
25
- return metadata.version;
25
+ if (typeof metadata !== 'object' || metadata === null || !('version' in metadata) || !('name' in metadata) ||
26
+ typeof metadata.version !== 'string' || typeof metadata.name !== 'string')
27
+ throw new Error('Missing package metadata');
28
+ return { name: metadata.name, version: metadata.version };
26
29
  }
27
30
  export async function startStdioServer() {
28
- const version = packageVersion();
29
- const application = createShoppingApplication({ version });
31
+ const { name, version } = packageMetadata();
32
+ const settings = loadSettings();
33
+ const update = await checkForUpdate({ currentVersion: version, packageName: name, dataDir: settings.dataDir, enabled: settings.updateCheck });
34
+ if (update.available)
35
+ console.error(`${name} ${update.latestVersion} is available (running ${version}).`);
36
+ const application = createShoppingApplication({ version, update });
30
37
  await connectStdioApplication(application);
31
38
  console.error(`continentept-mcp ${version} ready on stdio`);
32
39
  return application;
@@ -67,14 +74,14 @@ export async function main(args) {
67
74
  return (await runAutoLogin({ showBrowser: rest.includes('--show-browser') })) ? 0 : 1;
68
75
  }
69
76
  catch (error) {
70
- console.error(`Login failed: ${error instanceof Error ? error.message : 'unknown error'}`);
77
+ console.error(safeLoginMessage(error));
71
78
  return 1;
72
79
  }
73
80
  case 'install-browser':
74
81
  return runChromiumInstall(rest);
75
82
  case '-v':
76
83
  case '--version':
77
- console.log(packageVersion());
84
+ console.log(packageMetadata().version);
78
85
  return 0;
79
86
  case '-h':
80
87
  case '--help':
@@ -5,6 +5,7 @@ import { STOREFRONT_ORIGIN } from '../parsers/catalog.js';
5
5
  import { isLoginUrl, responseStatus } from './policy.js';
6
6
  import { CART_SUMMARY_URL } from './storefront.js';
7
7
  import { requestIO, acquireResource, checkRequest } from '../session/request.js';
8
+ import { LoginFailed, loginFailure } from '../session/login-errors.js';
8
9
  const LOGIN_ORIGIN = 'https://login.continente.pt';
9
10
  const EMAIL_INPUT = '#userNameCC';
10
11
  const PASSWORD_INPUT = '#password_input';
@@ -59,32 +60,43 @@ export class AuthenticationClient {
59
60
  this.logger('Login skipped: CONTINENTE_EMAIL and CONTINENTE_PASSWORD are required.');
60
61
  return false;
61
62
  }
62
- const browser = await acquireResource(() => this.launch({ headless: !this.options.showBrowser }), browser => browser.close());
63
+ let step = 'starting the browser';
64
+ let browser;
63
65
  let closing;
64
- const close = () => closing ??= browser.close();
66
+ const close = () => closing ??= browser?.close() ?? Promise.resolve();
65
67
  try {
68
+ const launched = browser = await acquireResource(() => this.launch({ headless: !this.options.showBrowser }), opened => opened.close());
66
69
  return await requestIO(async () => {
67
- const context = await acquireResource(() => browser.newContext({ userAgent: browserUserAgent(browser.version()), locale: 'pt-PT' }), context => context.close());
70
+ const context = await acquireResource(() => launched.newContext({ userAgent: browserUserAgent(launched.version()), locale: 'pt-PT' }), context => context.close());
68
71
  const page = await acquireResource(() => context.newPage(), page => page.close());
69
72
  this.logger('Signing in.');
73
+ step = 'opening the login page';
70
74
  await page.goto(`${STOREFRONT_ORIGIN}/login/`, { waitUntil: 'domcontentloaded', timeout: 30000 });
71
75
  await page.getByRole('button', { name: CONSENT_BUTTON }).click({ timeout: 5000 }).catch(() => { });
72
76
  const frame = await this.loginFrame(page);
77
+ step = 'entering the email';
73
78
  await this.submit(frame, EMAIL_INPUT, email);
74
79
  const leftLogin = page.waitForURL(url => url.origin === STOREFRONT_ORIGIN && !isLoginUrl(url.href), { timeout: 20000 }).catch(() => { });
80
+ step = 'entering the password';
75
81
  await this.submit(frame, PASSWORD_INPUT, password);
76
82
  await leftLogin;
77
83
  checkRequest();
84
+ step = 'checking the new session';
78
85
  if (!(await this.isSignedIn(context)))
79
- throw new Error('The website did not accept the login');
86
+ throw new LoginFailed('Sign-in failed: the website did not accept the login.');
87
+ step = 'saving the session';
80
88
  const cookies = SessionCookiesSchema.safeParse(await context.cookies());
81
89
  if (!cookies.success)
82
- throw new Error('The website returned an unexpected session');
90
+ throw new LoginFailed('Sign-in failed: the website returned an unexpected session.');
83
91
  await this.cookies.save(cookies.data);
84
92
  this.logger('Signed in; session saved.');
85
93
  return true;
86
94
  }, close);
87
95
  }
96
+ catch (error) {
97
+ // Playwright messages from these steps can contain the typed password or Cookie headers.
98
+ throw loginFailure(step, error);
99
+ }
88
100
  finally {
89
101
  await close();
90
102
  }
package/dist/config.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface Settings {
9
9
  readonly favoritesFile: string;
10
10
  /** Undefined keeps the session default. */
11
11
  readonly browserIdleMs: number | undefined;
12
+ /** Check npm once a day for a newer release. */
13
+ readonly updateCheck: boolean;
12
14
  }
13
15
  /** Read `KEY=value` lines. Surrounding quotes are removed; nothing is expanded or unescaped. */
14
16
  export declare function readDotEnv(text: string): Record<string, string>;
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
5
- const FILE_KEYS = new Set(['CONTINENTE_EMAIL', 'CONTINENTE_PASSWORD', 'CONTINENTE_BROWSER_IDLE_SECONDS']);
5
+ const FILE_KEYS = new Set(['CONTINENTE_EMAIL', 'CONTINENTE_PASSWORD', 'CONTINENTE_BROWSER_IDLE_SECONDS', 'CONTINENTE_UPDATE_CHECK']);
6
6
  const ASSIGNMENT = /^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/;
7
7
  /** Read `KEY=value` lines. Surrounding quotes are removed; nothing is expanded or unescaped. */
8
8
  export function readDotEnv(text) {
@@ -52,6 +52,7 @@ export function loadSettings(env = process.env) {
52
52
  cookieFile: join(dataDir, 'session.json'),
53
53
  favoritesFile: join(dataDir, 'favorites.json'),
54
54
  browserIdleMs: Number.isFinite(idleSeconds) && idleSeconds >= 0 ? idleSeconds * 1000 : undefined,
55
+ updateCheck: !['0', 'false', 'off', 'no'].includes(value('CONTINENTE_UPDATE_CHECK')?.trim().toLowerCase() ?? ''),
55
56
  };
56
57
  }
57
58
  export function hasCredentials(settings) {
@@ -38,3 +38,9 @@ export declare const FavoritesStatusSchema: z.ZodObject<{
38
38
  empty: z.ZodNullable<z.ZodBoolean>;
39
39
  }, z.core.$strict>;
40
40
  export type FavoritesStatus = z.infer<typeof FavoritesStatusSchema>;
41
+ export declare const UpdateStatusSchema: z.ZodObject<{
42
+ currentVersion: z.ZodNullable<z.ZodString>;
43
+ latestVersion: z.ZodNullable<z.ZodString>;
44
+ available: z.ZodBoolean;
45
+ }, z.core.$strict>;
46
+ export type UpdateStatus = z.infer<typeof UpdateStatusSchema>;
@@ -9,3 +9,6 @@ export const FavoritesStatusSchema = z.strictObject({
9
9
  state: z.enum(['missing', 'fresh', 'stale', 'unavailable']), fetchedAt: z.iso.datetime({ offset: true }).nullable(),
10
10
  expiresAt: z.iso.datetime({ offset: true }).nullable(), empty: z.boolean().nullable(),
11
11
  });
12
+ export const UpdateStatusSchema = z.strictObject({
13
+ currentVersion: z.string().nullable(), latestVersion: z.string().nullable(), available: z.boolean(),
14
+ });
@@ -1301,6 +1301,11 @@ export declare const SessionStatusOutput: z.ZodObject<{
1301
1301
  expiresAt: z.ZodNullable<z.ZodISODateTime>;
1302
1302
  empty: z.ZodNullable<z.ZodBoolean>;
1303
1303
  }, z.core.$strict>;
1304
+ update: z.ZodObject<{
1305
+ currentVersion: z.ZodNullable<z.ZodString>;
1306
+ latestVersion: z.ZodNullable<z.ZodString>;
1307
+ available: z.ZodBoolean;
1308
+ }, z.core.$strict>;
1304
1309
  }, z.core.$strict>;
1305
1310
  }, z.core.$strict>, z.ZodObject<{
1306
1311
  status: z.ZodLiteral<"error">;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { MAX_REFERENCE_LENGTH, isProductReference, CartSchema, ProductDetailsSchema, FavoriteProductSchema, OrderSchema, ProductSchema, PurchaseFrequencySchema, ToolErrorSchema, CartMutationResultSchema, CartItemIdSchema, OrderIdSchema, BrowserStatusSchema, FavoritesStatusSchema } from '../domain/index.js';
2
+ import { MAX_REFERENCE_LENGTH, isProductReference, CartSchema, ProductDetailsSchema, FavoriteProductSchema, OrderSchema, ProductSchema, PurchaseFrequencySchema, ToolErrorSchema, CartMutationResultSchema, CartItemIdSchema, OrderIdSchema, BrowserStatusSchema, FavoritesStatusSchema, UpdateStatusSchema } from '../domain/index.js';
3
3
  export const ProductReferenceInput = z.string().min(1).max(MAX_REFERENCE_LENGTH).regex(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*(?:\.html)?$/)
4
4
  .refine(isProductReference, 'Expected a numeric productId or a product slug ending in it')
5
5
  .describe('productId from another tool, such as "00123". A product slug ending in that ID (for example "leite-00123") also works.');
@@ -60,6 +60,6 @@ export const MutationOutput = outputSchema(ConfirmedMutationData);
60
60
  export const OrdersOutput = outputSchema(OrdersData);
61
61
  export const SingleOrderOutput = outputSchema(z.strictObject({ order: OrderSchema, unknownQuantityLines: integer }));
62
62
  export const FrequencyOutput = outputSchema(FrequencyData, { scan: FrequencyData.optional() });
63
- export const SessionStatusOutput = outputSchema(BrowserStatusSchema.extend({ favorites: FavoritesStatusSchema }));
63
+ export const SessionStatusOutput = outputSchema(BrowserStatusSchema.extend({ favorites: FavoritesStatusSchema, update: UpdateStatusSchema }));
64
64
  export const GetProductInput = z.strictObject({ productId: ProductReferenceInput });
65
65
  export const ProductDetailsOutput = outputSchema(ProductDetailsSchema);
@@ -1,6 +1,7 @@
1
1
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
2
  import type { ToolHandler } from './server.js';
3
3
  import type { SessionService } from '../ports.js';
4
+ import type { UpdateStatus } from '../domain/index.js';
4
5
  import type { CatalogService, FavoritesService, SearchResult } from '../services/catalog.js';
5
6
  import type { CartService } from '../services/cart.js';
6
7
  import type { OrderService, PurchaseHistoryService, OrderListOptions } from '../services/orders.js';
@@ -15,7 +16,8 @@ export interface ShoppingServices {
15
16
  export declare function formatProductSearchResults(products: SearchResult['page']['items']): string;
16
17
  export declare class ShoppingHandlers {
17
18
  private readonly services;
18
- constructor(services: ShoppingServices);
19
+ private readonly update;
20
+ constructor(services: ShoppingServices, update: UpdateStatus);
19
21
  private recover;
20
22
  searchProducts(query: string, limit: number): Promise<CallToolResult>;
21
23
  getProduct(reference: string): Promise<CallToolResult>;
@@ -30,4 +32,4 @@ export declare class ShoppingHandlers {
30
32
  listFrequentlyBoughtProducts(orderLimit: number, limit: number): Promise<CallToolResult>;
31
33
  getSessionStatus(): Promise<CallToolResult>;
32
34
  }
33
- export declare function registerShoppingTools(services: ShoppingServices): ToolHandler[];
35
+ export declare function registerShoppingTools(services: ShoppingServices, update?: UpdateStatus): ToolHandler[];
@@ -69,8 +69,10 @@ function mutationText(value, operation) {
69
69
  }
70
70
  export class ShoppingHandlers {
71
71
  services;
72
- constructor(services) {
72
+ update;
73
+ constructor(services, update) {
73
74
  this.services = services;
75
+ this.update = update;
74
76
  }
75
77
  recover(operation) {
76
78
  return withAuthenticationRecovery(operation, () => this.services.session.refreshAuthentication());
@@ -179,16 +181,18 @@ export class ShoppingHandlers {
179
181
  const favorites = await this.services.favorites.getCacheStatus();
180
182
  const credentials = status.credentials.hasEmail && status.credentials.hasPassword ? 'configured'
181
183
  : status.credentials.hasEmail === null ? 'unknown' : 'missing';
182
- return response({ ...status, favorites }, `Browser: ${status.browser}. Credentials: ${credentials}. Saved cookies: ${status.savedCookies}. ` +
183
- `Last observed login state: ${status.authentication.state}. Favorites snapshot: ${favorites.state}.`);
184
+ const update = this.update.available ? ` Update available: ${this.update.latestVersion} (running ${this.update.currentVersion}).` : '';
185
+ return response({ ...status, favorites, update: this.update }, `Browser: ${status.browser}. Credentials: ${credentials}. Saved cookies: ${status.savedCookies}. ` +
186
+ `Last observed login state: ${status.authentication.state}. Favorites snapshot: ${favorites.state}.${update}`);
184
187
  }
185
188
  }
186
189
  const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
187
190
  const ADD = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
188
191
  const UPDATE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true };
189
192
  const UNCERTAIN_WRITE = 'If the result has mutation.outcome "unknown", the cart may have changed: call get_cart before any further change and never repeat the request automatically.';
190
- export function registerShoppingTools(services) {
191
- const handlers = new ShoppingHandlers(services);
193
+ const NO_UPDATE_INFO = { currentVersion: null, latestVersion: null, available: false };
194
+ export function registerShoppingTools(services, update = NO_UPDATE_INFO) {
195
+ const handlers = new ShoppingHandlers(services, update);
192
196
  function tool(name, title, description, inputSchema, outputSchema, annotations, execute, cartMutation = false) {
193
197
  return { definition: { name, title, description, annotations }, inputSchema, outputSchema, cartMutation,
194
198
  execute: raw => services.session.run(() => execute(raw)) };
package/dist/runtime.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { Clock, Logger, FavoritesStore, CatalogGateway, FavoritesGateway, C
3
3
  import type { CookieStore } from './stores/files.js';
4
4
  import type { BrowserLauncher, StorefrontSession } from './session/browser.js';
5
5
  import type { ShoppingServices } from './mcp/shopping.js';
6
+ import type { UpdateStatus } from './domain/index.js';
6
7
  export declare const systemClock: Clock;
7
8
  /** Launch Playwright's Chromium, downloading it first if this machine does not have it yet. */
8
9
  export declare const launchBrowser: BrowserLauncher;
@@ -24,4 +25,5 @@ export declare function createShoppingServices(options?: RuntimeOptions): Shoppi
24
25
  export declare function createShoppingApplication(options: RuntimeOptions & {
25
26
  version: string;
26
27
  services?: ShoppingServices;
28
+ update?: UpdateStatus;
27
29
  }): import("./index.js").ContinenteMcpServer;
package/dist/runtime.js CHANGED
@@ -10,6 +10,7 @@ import { CartService } from './services/cart.js';
10
10
  import { OrderService, PurchaseHistoryService } from './services/orders.js';
11
11
  import { registerShoppingTools } from './mcp/shopping.js';
12
12
  import { SERVER_INSTRUCTIONS, basketPlannerPrompt } from './mcp/guide.js';
13
+ import { updateNotice } from './services/updates.js';
13
14
  export const systemClock = { now: () => new Date() };
14
15
  const chromiumInstaller = new ChromiumInstaller(async () => {
15
16
  // Skip per-percent progress lines; keep the start, finish and any errors.
@@ -43,7 +44,8 @@ export function createShoppingServices(options = {}) {
43
44
  }
44
45
  export function createShoppingApplication(options) {
45
46
  const services = options.services ?? createShoppingServices(options);
46
- return createApplication({ version: options.version, tools: registerShoppingTools(services),
47
- prompts: [basketPlannerPrompt], instructions: SERVER_INSTRUCTIONS,
47
+ const notice = options.update ? updateNotice(options.update) : null;
48
+ return createApplication({ version: options.version, tools: registerShoppingTools(services, options.update),
49
+ prompts: [basketPlannerPrompt], instructions: notice ? `${SERVER_INSTRUCTIONS}\n\n${notice}` : SERVER_INSTRUCTIONS,
48
50
  closeResources: () => services.session.shutdown?.() ?? services.session.run(() => services.session.closeBrowserSession()) });
49
51
  }
@@ -0,0 +1,21 @@
1
+ import type { UpdateStatus } from '../domain/index.js';
2
+ export declare const UPDATE_CHECK_INTERVAL_MS: number;
3
+ export declare const RELEASES_URL = "https://github.com/andredelgado/continente-mcp/releases/latest";
4
+ export interface UpdateCheckOptions {
5
+ readonly currentVersion: string;
6
+ readonly packageName: string;
7
+ readonly dataDir: string;
8
+ readonly enabled?: boolean;
9
+ readonly now?: () => Date;
10
+ readonly timeoutMs?: number;
11
+ readonly fetchLatest?: (packageName: string, signal: AbortSignal) => Promise<string>;
12
+ }
13
+ /** True when `candidate` is a later stable release than `current`. Prereleases never count. */
14
+ export declare function isNewerRelease(candidate: string, current: string): boolean;
15
+ /**
16
+ * Look up the latest published version at most once a day. Never throws: when offline or
17
+ * disabled it reports no update, reusing the last known answer when there is one.
18
+ */
19
+ export declare function checkForUpdate(options: UpdateCheckOptions): Promise<UpdateStatus>;
20
+ /** A line for the assistant's instructions, or null when there is nothing to say. */
21
+ export declare function updateNotice(status: UpdateStatus): string | null;
@@ -0,0 +1,70 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { writePrivateJson } from '../stores/files.js';
5
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
6
+ export const RELEASES_URL = 'https://github.com/andredelgado/continente-mcp/releases/latest';
7
+ const CacheSchema = z.object({ checkedAt: z.iso.datetime({ offset: true }), latestVersion: z.string() });
8
+ const stableParts = (version) => /^(\d+)\.(\d+)\.(\d+)$/.exec(version)?.slice(1).map(Number) ?? null;
9
+ /** True when `candidate` is a later stable release than `current`. Prereleases never count. */
10
+ export function isNewerRelease(candidate, current) {
11
+ const next = stableParts(candidate);
12
+ const now = stableParts(current);
13
+ if (!next || !now)
14
+ return false;
15
+ for (let index = 0; index < 3; index++) {
16
+ if (next[index] !== now[index])
17
+ return (next[index] ?? 0) > (now[index] ?? 0);
18
+ }
19
+ return false;
20
+ }
21
+ async function registryLatest(packageName, signal) {
22
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, { signal, headers: { accept: 'application/json' } });
23
+ if (!response.ok)
24
+ throw new Error(`npm registry returned HTTP ${response.status}`);
25
+ return z.object({ version: z.string() }).parse(await response.json()).version;
26
+ }
27
+ async function withTimeout(operation, timeoutMs) {
28
+ const controller = new AbortController();
29
+ let timer;
30
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('timed out')); }, timeoutMs); });
31
+ try {
32
+ return await Promise.race([operation(controller.signal), timeout]);
33
+ }
34
+ finally {
35
+ clearTimeout(timer);
36
+ }
37
+ }
38
+ /**
39
+ * Look up the latest published version at most once a day. Never throws: when offline or
40
+ * disabled it reports no update, reusing the last known answer when there is one.
41
+ */
42
+ export async function checkForUpdate(options) {
43
+ const unknown = { currentVersion: options.currentVersion, latestVersion: null, available: false };
44
+ if (options.enabled === false)
45
+ return unknown;
46
+ const file = join(options.dataDir, 'update-check.json');
47
+ const now = (options.now ?? (() => new Date()))();
48
+ let cached = null;
49
+ try {
50
+ cached = CacheSchema.parse(JSON.parse(await readFile(file, 'utf8')));
51
+ }
52
+ catch { /* no usable cache */ }
53
+ let latest = cached?.latestVersion ?? null;
54
+ const age = cached ? now.getTime() - Date.parse(cached.checkedAt) : Infinity;
55
+ if (!(age >= 0 && age < UPDATE_CHECK_INTERVAL_MS)) {
56
+ try {
57
+ latest = await withTimeout(signal => (options.fetchLatest ?? registryLatest)(options.packageName, signal), options.timeoutMs ?? 1500);
58
+ await writePrivateJson(file, { checkedAt: now.toISOString(), latestVersion: latest }).catch(() => { });
59
+ }
60
+ catch { /* offline or slow: keep the last known answer */ }
61
+ }
62
+ return { currentVersion: options.currentVersion, latestVersion: latest, available: latest !== null && isNewerRelease(latest, options.currentVersion) };
63
+ }
64
+ /** A line for the assistant's instructions, or null when there is nothing to say. */
65
+ export function updateNotice(status) {
66
+ if (!status.available || !status.latestVersion)
67
+ return null;
68
+ return `Update available: continentept-mcp ${status.latestVersion} is out (this is ${status.currentVersion}). Mention it to the user once. `
69
+ + `Claude Desktop extension users download continente-mcp.mcpb from ${RELEASES_URL} and open it; npx users restart their client.`;
70
+ }
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { createSerialTaskQueue } from './queue.js';
3
+ import { safeLoginMessage } from './login-errors.js';
3
4
  import { checkRequest, currentRequest, inRequest, RequestScope, acquireResource } from './request.js';
4
5
  /** A desktop Chrome user agent matching the launched Chromium, without the "HeadlessChrome" marker. */
5
6
  export function browserUserAgent(version, platform = process.platform) {
@@ -95,7 +96,7 @@ export class BrowserSession {
95
96
  return authenticated;
96
97
  }
97
98
  catch (error) {
98
- this.logger(`Login failed: ${error instanceof Error ? error.message : 'unknown error'}`);
99
+ this.logger(safeLoginMessage(error));
99
100
  return false;
100
101
  }
101
102
  });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A sign-in failure with a fixed, loggable message. Browser automation errors from credential
3
+ * entry or authenticated requests can embed typed values and Cookie headers in their message,
4
+ * so they are never passed on; only the failed step is reported.
5
+ */
6
+ export declare class LoginFailed extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** Wrap any error from a sign-in step in a LoginFailed that names only the step. */
10
+ export declare function loginFailure(step: string, error: unknown): Error;
11
+ /** The only text about a sign-in error that may reach logs or the terminal. */
12
+ export declare function safeLoginMessage(error: unknown): string;
@@ -0,0 +1,25 @@
1
+ import { BrowserUnavailable } from './chromium.js';
2
+ import { RequestStopped } from './request.js';
3
+ /**
4
+ * A sign-in failure with a fixed, loggable message. Browser automation errors from credential
5
+ * entry or authenticated requests can embed typed values and Cookie headers in their message,
6
+ * so they are never passed on; only the failed step is reported.
7
+ */
8
+ export class LoginFailed extends Error {
9
+ constructor(message) { super(message); this.name = 'LoginFailed'; }
10
+ }
11
+ /** Wrap any error from a sign-in step in a LoginFailed that names only the step. */
12
+ export function loginFailure(step, error) {
13
+ if (error instanceof LoginFailed || error instanceof RequestStopped || error instanceof BrowserUnavailable)
14
+ return error;
15
+ const timedOut = error instanceof Error && error.name === 'TimeoutError';
16
+ return new LoginFailed(`Sign-in failed while ${step}${timedOut ? ' (timed out)' : ''}.`);
17
+ }
18
+ /** The only text about a sign-in error that may reach logs or the terminal. */
19
+ export function safeLoginMessage(error) {
20
+ if (error instanceof LoginFailed || error instanceof BrowserUnavailable)
21
+ return error.message;
22
+ if (error instanceof RequestStopped)
23
+ return `Sign-in stopped (${error.code}).`;
24
+ return 'Sign-in failed with an unexpected error.';
25
+ }
@@ -14,8 +14,10 @@ manual setups.
14
14
  | `CONTINENTE_CREDENTIALS_FILE` | `<data dir>/credentials.env` | Location of the credentials file. Set this in the MCP client configuration. |
15
15
  | `CONTINENTE_DATA_DIR` | `~/.continentept-mcp` | Directory for the saved session, the favorites snapshot and, by default, the credentials file. |
16
16
  | `CONTINENTE_BROWSER_IDLE_SECONDS` | `300` | Close the browser after this many idle seconds. `0` keeps it open until the server stops. |
17
+ | `CONTINENTE_UPDATE_CHECK` | `1` | Set to `0` to stop the daily check for a newer release on npm. |
17
18
 
18
- On Windows, `~` is `%USERPROFILE%`.
19
+ `~` is your home directory: `HOME`, or `%USERPROFILE%` when `HOME` is not set (the usual case on
20
+ Windows).
19
21
 
20
22
  ## Credentials file
21
23
 
@@ -29,7 +31,8 @@ CONTINENTE_PASSWORD=your-password
29
31
 
30
32
  One `KEY=value` per line. Quotes around a value are removed, `export ` prefixes and `#`
31
33
  comment lines are allowed, and nothing is expanded. The file may set `CONTINENTE_EMAIL`,
32
- `CONTINENTE_PASSWORD` and `CONTINENTE_BROWSER_IDLE_SECONDS`; other keys are ignored. A variable
34
+ `CONTINENTE_PASSWORD`, `CONTINENTE_BROWSER_IDLE_SECONDS` and `CONTINENTE_UPDATE_CHECK`; other keys
35
+ are ignored. A variable
33
36
  set in the environment always wins over the file, even when it is empty. See
34
37
  [`.env.example`](../.env.example).
35
38
 
@@ -107,9 +110,29 @@ complete it in that window; the session is saved once the account is signed in.
107
110
  | `session.json` | Website session cookies. Treat it like a password. |
108
111
  | `favorites.json` | Favorites snapshot, tied to the configured account and current session. |
109
112
  | `favorites.json.bak` | Previous valid favorites snapshot. |
113
+ | `update-check.json` | The latest version seen on npm and when it was checked. |
110
114
 
111
- Files are written atomically and readable only by your user. A new session or a different
112
- account makes the favorites snapshot stale until it is refreshed. Delete the directory to
113
- remove everything the server has stored.
115
+ Files are written atomically. On macOS and Linux they are created readable only by your user;
116
+ on Windows they inherit the folder's permissions, so restrict the folder as described above.
117
+ The server does not change the permissions of a folder that already exists.
118
+
119
+ If you sign in with a different account or the saved session is replaced, the old favorites
120
+ snapshot is no longer used and the next request fetches a fresh one. A snapshot of the same
121
+ account that is more than 24 hours old is still used, marked as stale.
122
+
123
+ Deleting the data directory removes everything listed above. A credentials file kept
124
+ elsewhere (`CONTINENTE_CREDENTIALS_FILE`) and the downloaded Chromium, which lives in
125
+ Playwright's own cache folder, are separate.
126
+
127
+ Sign-in errors are logged as short, fixed messages that name the step that failed. They never
128
+ include your password, email or session cookies.
129
+
130
+ ## Update check
131
+
132
+ When the server starts, it asks npm for the latest published version, at most once a day and
133
+ with a short timeout. The request sends nothing about you or your account. If a newer stable
134
+ release exists, the assistant is told about it and `get_session_status` reports it. Nothing is
135
+ downloaded or installed automatically. When offline, the check is skipped silently. Set
136
+ `CONTINENTE_UPDATE_CHECK=0` to turn it off.
114
137
 
115
138
  One server process serves one account.
package/docs/tools.md CHANGED
@@ -177,7 +177,8 @@ top-level `scan` field.
177
177
  Reports local state without opening a browser or contacting the website: `browser`
178
178
  (`closed`, `starting`, `ready`), `credentials` (`hasEmail`, `hasPassword`), `savedCookies`
179
179
  (`available`, `missing`, `unavailable`), `authentication` (the last observed login state and
180
- when it was observed) and `favorites` (snapshot state and expiry). It never returns
180
+ when it was observed), `favorites` (snapshot state and expiry) and `update` (`currentVersion`,
181
+ `latestVersion` and whether a newer release is `available`). It never returns
181
182
  credentials, cookies or file paths.
182
183
 
183
184
  ## Error codes
@@ -205,7 +206,8 @@ Invalid arguments and unknown tools are JSON-RPC errors (`-32602`), not tool res
205
206
 
206
207
  The server sends **instructions** when a client connects: the quantity, cart-change and
207
208
  uncertain-outcome rules described above, in a few lines. Clients such as Claude include them
208
- in the assistant's context automatically.
209
+ in the assistant's context automatically. When a newer release exists, the instructions also
210
+ say so, so the assistant can tell the user.
209
211
 
210
212
  It also offers one **prompt**, `basket-planner` ("Plan a Continente basket"). It returns the
211
213
  full shopping workflow as a user message. Its optional `request` argument, such as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "continentept-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.andredelgado/continente-mcp",
5
5
  "description": "MCP server for Continente.pt: search products, check sale rules, read favorites and orders, and make verified cart changes.",
6
6
  "keywords": [