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 +3 -0
- package/CHANGELOG.md +16 -1
- package/README.md +14 -2
- package/dist/cli.js +16 -9
- package/dist/clients/authentication.js +17 -5
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -1
- package/dist/domain/session.d.ts +6 -0
- package/dist/domain/session.js +3 -0
- package/dist/mcp/contracts.d.ts +5 -0
- package/dist/mcp/contracts.js +2 -2
- package/dist/mcp/shopping.d.ts +4 -2
- package/dist/mcp/shopping.js +9 -5
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.js +4 -2
- package/dist/services/updates.d.ts +21 -0
- package/dist/services/updates.js +70 -0
- package/dist/session/browser.js +2 -1
- package/dist/session/login-errors.d.ts +12 -0
- package/dist/session/login-errors.js +25 -0
- package/docs/configuration.md +28 -5
- package/docs/tools.md +4 -2
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
29
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
|
|
63
|
+
let step = 'starting the browser';
|
|
64
|
+
let browser;
|
|
63
65
|
let closing;
|
|
64
|
-
const close = () => closing ??= browser
|
|
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(() =>
|
|
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
|
|
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
|
|
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) {
|
package/dist/domain/session.d.ts
CHANGED
|
@@ -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>;
|
package/dist/domain/session.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/mcp/contracts.d.ts
CHANGED
|
@@ -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">;
|
package/dist/mcp/contracts.js
CHANGED
|
@@ -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);
|
package/dist/mcp/shopping.d.ts
CHANGED
|
@@ -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
|
-
|
|
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[];
|
package/dist/mcp/shopping.js
CHANGED
|
@@ -69,8 +69,10 @@ function mutationText(value, operation) {
|
|
|
69
69
|
}
|
|
70
70
|
export class ShoppingHandlers {
|
|
71
71
|
services;
|
|
72
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
+
}
|
package/dist/session/browser.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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)
|
|
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
|
+
"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": [
|