@nooticr/mcp 1.26.39 → 1.26.41
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/README.md +110 -11
- package/dist/auth.js +78 -7
- package/dist/config.js +11 -0
- package/dist/index.js +219 -9
- package/dist/shared/amazon.js +39 -1
- package/dist/shared/api-key.js +20 -0
- package/dist/shared/marketplace.js +54 -5
- package/dist/shared/nooticr.js +45 -3
- package/dist/shared/tools-def.js +4 -4
- package/dist/shared/tools.js +19 -1
- package/dist/shared/ui-template.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -145,9 +145,9 @@ a `scanId` for the rest.
|
|
|
145
145
|
| `scan_amazon_category` | 3 per listing collected (30 by default) | The category, collected: listings with prices, star histograms, Amazon's own review-aspect counts and the review text itself, plus the arithmetic over them — price spread, rating spread, which aspects recur across how many brands, and the share of each listing's ratings sitting at 1-2 stars, read from the histogram rather than from the handful of reviews a scrape returns. Pass `query` to search the category, `asins` to pin the competitors a customer named, or both; named competitors keep their place at the front. Because the price is set by an argument, it confirms the total before spending it. |
|
|
146
146
|
| `amazon_scan_status` | free | Picks a running collection back up by `scanId` — the listings collected since, with the same rollup. Free: it is the poll `scan_amazon_category` asked for, and billing the second half of one answer would charge for a wait the server chose. |
|
|
147
147
|
| `get_amazon_product` | 3 | One listing by ASIN or URL: price, rating, the full star histogram, features, specs, Amazon's review digest and aspect breakdown, and the reviews. |
|
|
148
|
-
| `scan_marketplace_category` | 3 per product collected (30 by default) | The same category read on
|
|
148
|
+
| `scan_marketplace_category` | 3 per product collected (30 by default) | The same category read on eleven more sites — AliExpress, Cdiscount, Flipkart, Mercado Libre, Lazada, Otto, Rakuten, Trendyol, Jumia, Vinted and Temu. Vinted is resale rather than retail, so its `rating` describes the seller and the scan says so in `ratingScope` — do not read it as a product's reviews. `marketplace` picks the site; `market` picks the storefront for the sites that have several, and the argument names the real set per site so a model cannot send Otto a Lazada region. Same rollup, same confirmation before spending. |
|
|
149
149
|
| `marketplace_scan_status` | free | Picks a running marketplace collection back up by `scanId` and the same `marketplace`. Free, for the same reason `amazon_scan_status` is. |
|
|
150
|
-
| `get_marketplace_product` | 3 | One product from any of the
|
|
150
|
+
| `get_marketplace_product` | 3 | One product from any of the twelve sites, by id or URL: price, rating, the full star histogram, specs and the reviews. |
|
|
151
151
|
| `show_amazon_category_insights` | free | Draws the read **your model** wrote — purchase drivers, barriers, what each brand does well, the gaps, the positioning angles — beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. The conclusions are attributed to your model, never presented as a nooticr rating of anyone's product. Makes no requests. |
|
|
152
152
|
|
|
153
153
|
### Make something
|
|
@@ -337,6 +337,76 @@ npx @nooticr/mcp --http # remote HTTP with OAuth (for OpenAI Agents SDK)
|
|
|
337
337
|
(mode `0600`). If your browser cannot be opened automatically, copy the URL it
|
|
338
338
|
prints into a browser manually.
|
|
339
339
|
|
|
340
|
+
## Running on a server (no browser)
|
|
341
|
+
|
|
342
|
+
`login` and the OAuth flow both need a browser once, and both leave behind a
|
|
343
|
+
15-minute access token kept alive by a refresh token. That is the wrong shape
|
|
344
|
+
for an integration running server-side: nothing there can complete a consent
|
|
345
|
+
screen, and a refresh chain that breaks has to be repaired by hand.
|
|
346
|
+
|
|
347
|
+
An **API key** is the other way in. Mint one from a browser at
|
|
348
|
+
[mcp.nooticr.com/dashboard](https://mcp.nooticr.com/dashboard) — sign in, name
|
|
349
|
+
the key, copy it — or from a machine you *can* sign in on, once:
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
npx @nooticr/mcp login # the one interactive step
|
|
353
|
+
npx @nooticr/mcp api-key create --name "aybee-prod" # prints the key, once
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Then give it to the deployment. There is nothing else to configure and nothing
|
|
357
|
+
to refresh — the key stays valid until you revoke it.
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
# stdio, as a subprocess of your own service
|
|
361
|
+
NOOTICR_API_KEY=nk_... npx @nooticr/mcp
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
On a server, prefer the environment variable: it is what every container
|
|
365
|
+
runtime and secret manager already injects, and it leaves nothing on a disk
|
|
366
|
+
that a redeploy is going to throw away. Where setting one is the awkward part
|
|
367
|
+
— a laptop, or a client like Claude Desktop that does not inherit your shell —
|
|
368
|
+
store the key in the credentials file instead:
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
npx @nooticr/mcp login --api-key nk_...
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
That checks the key against the server before writing it, so a typo fails
|
|
375
|
+
while you are still there to fix it, and then behaves exactly like the
|
|
376
|
+
environment variable: no browser, no refresh, valid until revoked. The file
|
|
377
|
+
holds one credential, so this replaces any session an earlier `login` left
|
|
378
|
+
behind (and `NOOTICR_API_KEY` still overrides it).
|
|
379
|
+
|
|
380
|
+
```http
|
|
381
|
+
# or against the remote endpoint, as an ordinary bearer token —
|
|
382
|
+
# no /authorize, no PKCE, no redirect URI
|
|
383
|
+
POST https://mcp.nooticr.com/mcp
|
|
384
|
+
Authorization: Bearer nk_...
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Managing them, from the dashboard's **API keys** card or the CLI:
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
npx @nooticr/mcp api-key create --name "staging" --expires-in-days 90
|
|
391
|
+
npx @nooticr/mcp api-key list
|
|
392
|
+
npx @nooticr/mcp api-key revoke <id>
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Every command takes `--json` for scripting. A few things worth knowing:
|
|
396
|
+
|
|
397
|
+
- The key is shown **once**, by `api-key create`. nooticr stores only a hash of
|
|
398
|
+
it and cannot show it again; `list` shows the head (`nk_1a2b3c4d…`) so you
|
|
399
|
+
can tell keys apart.
|
|
400
|
+
- A key carries the scope of whoever created it — their account, and the
|
|
401
|
+
workspace they were in (`--workspace-id` picks another one you belong to).
|
|
402
|
+
- A key **cannot create or revoke keys**. That is deliberate: it means a leaked
|
|
403
|
+
key cannot mint a replacement that outlives revoking the original.
|
|
404
|
+
- `--expires-in-days` is optional. Without it a key does not expire on its own.
|
|
405
|
+
- Revoking takes effect immediately at the API. The hosted remote endpoint
|
|
406
|
+
caches the "this key is real" answer for up to five minutes, which only ever
|
|
407
|
+
lets a revoked key see the tool list — every call that touches your account
|
|
408
|
+
is checked against nooticr-server on the spot.
|
|
409
|
+
|
|
340
410
|
## Usage in Claude Desktop
|
|
341
411
|
|
|
342
412
|
After `npx @nooticr/mcp login`, add to `claude_desktop_config.json`:
|
|
@@ -455,6 +525,11 @@ nooticr-mcp --stdio Same as above
|
|
|
455
525
|
nooticr-mcp --http [--port N] Start the remote HTTP transport with OAuth (default port 3457)
|
|
456
526
|
nooticr-mcp login Sign in to nooticr via Google in your browser
|
|
457
527
|
nooticr-mcp login --email ... --password ... Password login
|
|
528
|
+
nooticr-mcp login --api-key nk_... Sign in with an API key, no browser
|
|
529
|
+
nooticr-mcp api-key create [--name N] [--expires-in-days D] [--workspace-id W] [--json]
|
|
530
|
+
Mint a key for a server with no browser
|
|
531
|
+
nooticr-mcp api-key list [--json] List this account's keys
|
|
532
|
+
nooticr-mcp api-key revoke <id> [--json] Revoke one
|
|
458
533
|
nooticr-mcp --help Show help
|
|
459
534
|
```
|
|
460
535
|
|
|
@@ -463,7 +538,8 @@ nooticr-mcp --help Show help
|
|
|
463
538
|
| Variable | Default | Description |
|
|
464
539
|
|----------|---------|-------------|
|
|
465
540
|
| `NOOTICR_BASE_URL` | `https://api.nooticr.com` | nooticr server base URL (trailing slash stripped) |
|
|
466
|
-
| `NOOTICR_ACCESS_TOKEN` | — | nooticr JWT access token; takes priority over
|
|
541
|
+
| `NOOTICR_ACCESS_TOKEN` | — | nooticr JWT access token; takes priority over everything below |
|
|
542
|
+
| `NOOTICR_API_KEY` | — | nooticr API key from `api-key create` — no browser, no refresh, valid until revoked; takes priority over the credentials file (including a key `login --api-key` put there) |
|
|
467
543
|
| `NOOTICR_CREDENTIALS_FILE` | `~/.config/nooticr-mcp/credentials.json` | token store path |
|
|
468
544
|
| `NOOTICR_PUBLIC_URL` | `http://localhost:3457` | public base URL advertised in OAuth metadata (HTTP mode) |
|
|
469
545
|
| `NOOTICR_PORT` | `3457` | port for `--http` and `login` |
|
|
@@ -471,10 +547,20 @@ nooticr-mcp --help Show help
|
|
|
471
547
|
|
|
472
548
|
## How authentication works
|
|
473
549
|
|
|
474
|
-
**stdio mode** (Claude Desktop, Cursor): the server uses the
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
550
|
+
**stdio mode** (Claude Desktop, Cursor): the server uses the first credential
|
|
551
|
+
it finds — `NOOTICR_ACCESS_TOKEN`, then `NOOTICR_API_KEY`, then the credentials
|
|
552
|
+
file written by `login`. That file holds one credential, whichever `login` last
|
|
553
|
+
wrote: an API key (`login --api-key`) or a session. A session token that has
|
|
554
|
+
expired is refreshed with the stored refresh token, and a `401` from the
|
|
555
|
+
nooticr API is retried once after a refresh. An API key is never refreshed
|
|
556
|
+
wherever it came from: it does not expire, so a `401` on one means revoked or
|
|
557
|
+
mistyped, and retrying would only hide that.
|
|
558
|
+
|
|
559
|
+
**API key** (any transport, no browser): send it as the bearer token, or set
|
|
560
|
+
`NOOTICR_API_KEY`. nooticr-server resolves the key to its owner and the
|
|
561
|
+
workspace it was scoped to, and every authorization check downstream sees
|
|
562
|
+
exactly what it would have seen for a signed-in session. See
|
|
563
|
+
[Running on a server](#running-on-a-server-no-browser).
|
|
478
564
|
|
|
479
565
|
**HTTP mode** (OpenAI Agents SDK, remote clients): the server runs its own
|
|
480
566
|
OAuth 2.0 authorization server (Authorization Code + PKCE S256, public client,
|
|
@@ -488,7 +574,9 @@ per the MCP 2025-03-26 spec):
|
|
|
488
574
|
one-time code
|
|
489
575
|
- `POST /token` — verifies PKCE and issues an opaque Bearer token bound to the
|
|
490
576
|
nooticr session (valid 1 hour)
|
|
491
|
-
- every MCP RPC validates the Bearer token against the session map
|
|
577
|
+
- every MCP RPC validates the Bearer token against the session map — or, when
|
|
578
|
+
the bearer is an API key, forwards it to nooticr-server as the caller's own
|
|
579
|
+
credential, which is what makes this endpoint usable with no browser at all
|
|
492
580
|
|
|
493
581
|
## Supported URLs
|
|
494
582
|
|
|
@@ -508,8 +596,14 @@ and text** posts.
|
|
|
508
596
|
|
|
509
597
|
## Troubleshooting
|
|
510
598
|
|
|
511
|
-
- **`Not authenticated with nooticr` / 401**: run `npx @nooticr/mcp login
|
|
512
|
-
`
|
|
599
|
+
- **`Not authenticated with nooticr` / 401**: run `npx @nooticr/mcp login`, or
|
|
600
|
+
set `NOOTICR_API_KEY` (see [Running on a server](#running-on-a-server-no-browser)).
|
|
601
|
+
- **`unknown, revoked or expired nooticr API key`**: the key is not one this
|
|
602
|
+
account has live. `npx @nooticr/mcp api-key list` shows every key and its
|
|
603
|
+
state, revoked ones included.
|
|
604
|
+
- **`API keys cannot manage API keys`**: `api-key create`/`list`/`revoke` need a
|
|
605
|
+
signed-in session, which is why they ignore a key in `NOOTICR_API_KEY` *and*
|
|
606
|
+
one stored by `login --api-key`. Run `npx @nooticr/mcp login` first.
|
|
513
607
|
- **402 paywall / `insufficient MCP credits`**: your nooticr account is out of
|
|
514
608
|
credits. Prices are listed per tool in [Tools](#tools) — 1 credit for a post
|
|
515
609
|
lookup or transcript, 2 for discovery and for a tool that makes one fetch, 3
|
|
@@ -541,7 +635,12 @@ package for **Claude Desktop**, **Cursor**, and **OpenAI Agents SDK**.
|
|
|
541
635
|
(in-memory).
|
|
542
636
|
- Access tokens are opaque, random, and bound to the in-memory session map;
|
|
543
637
|
they expire after 1 hour. Restarting the server invalidates all sessions.
|
|
544
|
-
-
|
|
638
|
+
- API keys are stored as a sha256 hash, never in the clear, so a database dump
|
|
639
|
+
cannot be replayed against the API. They are scoped to one account and one
|
|
640
|
+
workspace, carry their owner's role as it is *now* rather than as it was when
|
|
641
|
+
the key was minted, and cannot be used to create or revoke keys.
|
|
642
|
+
- Never share your credentials file, `NOOTICR_ACCESS_TOKEN` or
|
|
643
|
+
`NOOTICR_API_KEY`.
|
|
545
644
|
|
|
546
645
|
## Development
|
|
547
646
|
|
package/dist/auth.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token storage + resolution.
|
|
3
3
|
*
|
|
4
|
-
* Priority: env NOOTICR_ACCESS_TOKEN > credentials file
|
|
5
|
-
*
|
|
4
|
+
* Priority: env NOOTICR_ACCESS_TOKEN > env NOOTICR_API_KEY > credentials file
|
|
5
|
+
* (an API key stored by `login --api-key`, else a session token, refreshed
|
|
6
|
+
* when expired) > per-session tokens (HTTP OAuth mode only).
|
|
7
|
+
*
|
|
8
|
+
* `NOOTICR_ACCESS_TOKEN` stays first because it has always been the explicit
|
|
9
|
+
* "use exactly this token" override, and someone who exports one for a single
|
|
10
|
+
* debugging run should get it. An API key sits directly behind it: it is the
|
|
11
|
+
* credential a server holds permanently, so it outranks anything in a file.
|
|
12
|
+
*
|
|
13
|
+
* The file holds *one* credential, whichever `login` last wrote. A store
|
|
14
|
+
* carrying both a key and a session would make "which am I signed in as"
|
|
15
|
+
* unanswerable — and, worse, let a rejected key quietly fall back to a stale
|
|
16
|
+
* session belonging to someone else.
|
|
6
17
|
*/
|
|
7
18
|
import fs from "node:fs";
|
|
8
19
|
import path from "node:path";
|
|
20
|
+
import { getApiKey } from "./config.js";
|
|
9
21
|
import { NooticrClient, NooticrError } from "./nooticr.js";
|
|
10
22
|
const REFRESH_BEFORE_EXPIRY_MS = 30_000;
|
|
11
23
|
export class NooticrAuthError extends Error {
|
|
@@ -40,7 +52,7 @@ export class AuthManager {
|
|
|
40
52
|
try {
|
|
41
53
|
const raw = await fs.promises.readFile(this.credentialsFile, "utf8");
|
|
42
54
|
const parsed = JSON.parse(raw);
|
|
43
|
-
if (parsed && typeof parsed.accessToken === "string") {
|
|
55
|
+
if (parsed && (typeof parsed.accessToken === "string" || typeof parsed.apiKey === "string")) {
|
|
44
56
|
return parsed;
|
|
45
57
|
}
|
|
46
58
|
return null;
|
|
@@ -63,15 +75,34 @@ export class AuthManager {
|
|
|
63
75
|
/**
|
|
64
76
|
* Resolves the current access token for a request. `session` provides the
|
|
65
77
|
* per-session tokens issued via our own OAuth /token endpoint (HTTP mode).
|
|
78
|
+
*
|
|
79
|
+
* `allowApiKey` exists for the one caller that must not use one: the
|
|
80
|
+
* `api-key` CLI commands. The backend refuses key management to a
|
|
81
|
+
* key-authenticated caller, so resolving a key there would turn "create me
|
|
82
|
+
* a second key" into a 403 that reads like a permissions bug rather than
|
|
83
|
+
* the deliberate bound it is. It skips both places a key can live — the
|
|
84
|
+
* environment and the credentials file — since the bound is about the
|
|
85
|
+
* credential, not about where it was found.
|
|
66
86
|
*/
|
|
67
|
-
async getAccessToken(session) {
|
|
87
|
+
async getAccessToken(session, { allowApiKey = true } = {}) {
|
|
68
88
|
const envToken = process.env.NOOTICR_ACCESS_TOKEN;
|
|
69
89
|
if (envToken) {
|
|
70
90
|
this.lastSource = "env";
|
|
71
91
|
this.lastRefreshToken = undefined;
|
|
72
92
|
return envToken;
|
|
73
93
|
}
|
|
94
|
+
const apiKey = allowApiKey ? getApiKey() : undefined;
|
|
95
|
+
if (apiKey) {
|
|
96
|
+
this.lastSource = "apiKey";
|
|
97
|
+
this.lastRefreshToken = undefined;
|
|
98
|
+
return apiKey;
|
|
99
|
+
}
|
|
74
100
|
const store = await this.loadStore();
|
|
101
|
+
if (allowApiKey && store?.apiKey) {
|
|
102
|
+
this.lastSource = "apiKey";
|
|
103
|
+
this.lastRefreshToken = undefined;
|
|
104
|
+
return store.apiKey;
|
|
105
|
+
}
|
|
75
106
|
if (store?.accessToken && !isTokenExpired(store)) {
|
|
76
107
|
this.lastSource = "file";
|
|
77
108
|
this.lastRefreshToken = store.refreshToken;
|
|
@@ -101,6 +132,10 @@ export class AuthManager {
|
|
|
101
132
|
* the current token. Returns true when a new token is available.
|
|
102
133
|
*/
|
|
103
134
|
async onUnauthorized(session) {
|
|
135
|
+
// A key has nothing to redeem: a 401 on one means revoked, expired or
|
|
136
|
+
// mistyped, and retrying with the same string forever would hide that.
|
|
137
|
+
if (this.lastSource === "apiKey")
|
|
138
|
+
return false;
|
|
104
139
|
let refreshToken = this.lastRefreshToken;
|
|
105
140
|
if (this.lastSource === "session" && session?.refreshToken) {
|
|
106
141
|
refreshToken = session.refreshToken;
|
|
@@ -131,7 +166,22 @@ export class AuthManager {
|
|
|
131
166
|
await this.saveStore(store);
|
|
132
167
|
this.lastSource = "file";
|
|
133
168
|
this.lastRefreshToken = store.refreshToken;
|
|
134
|
-
|
|
169
|
+
// `session.accessToken` rather than `store.accessToken`: the guard above
|
|
170
|
+
// has already narrowed it, and the store's field is optional now that a
|
|
171
|
+
// credentials file may hold an API key instead of a session.
|
|
172
|
+
return session.accessToken;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Persists an API key as this machine's credential, replacing whatever
|
|
176
|
+
* `login` last wrote. For clients that do not inherit a shell environment
|
|
177
|
+
* (Claude Desktop, Cursor) and for a laptop where editing JSON to set an
|
|
178
|
+
* env var is the annoying part; a server should still be given
|
|
179
|
+
* `NOOTICR_API_KEY` and left with nothing on disk.
|
|
180
|
+
*/
|
|
181
|
+
async persistApiKey(apiKey, user) {
|
|
182
|
+
await this.saveStore({ apiKey, user });
|
|
183
|
+
this.lastSource = "apiKey";
|
|
184
|
+
this.lastRefreshToken = undefined;
|
|
135
185
|
}
|
|
136
186
|
/**
|
|
137
187
|
* Persists a session obtained from login/OAuth completion.
|
|
@@ -150,7 +200,8 @@ export class AuthManager {
|
|
|
150
200
|
}
|
|
151
201
|
ensureUnauthenticatedError() {
|
|
152
202
|
throw new NooticrAuthError("Not authenticated with nooticr. Run `npx nooticr-mcp login` to sign in " +
|
|
153
|
-
"with Google, or
|
|
203
|
+
"with Google, or — on a server with no browser — set NOOTICR_API_KEY " +
|
|
204
|
+
"to a key from `npx nooticr-mcp api-key create` " +
|
|
154
205
|
"(see `npx nooticr-mcp --help`).");
|
|
155
206
|
}
|
|
156
207
|
}
|
|
@@ -161,7 +212,27 @@ export function createStdioTokenProvider(auth) {
|
|
|
161
212
|
onUnauthorized: async () => auth.onUnauthorized(),
|
|
162
213
|
};
|
|
163
214
|
}
|
|
164
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* Builds a TokenProvider for the `api-key` CLI commands: env access token or
|
|
217
|
+
* the credentials file, never `NOOTICR_API_KEY`. See `getAccessToken`.
|
|
218
|
+
*/
|
|
219
|
+
export function createManagementTokenProvider(auth) {
|
|
220
|
+
return {
|
|
221
|
+
getAccessToken: async () => auth.getAccessToken(undefined, { allowApiKey: false }),
|
|
222
|
+
onUnauthorized: async () => auth.onUnauthorized(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Builds a TokenProvider for one caller's own API key — the headless path
|
|
227
|
+
* through HTTP mode, where the bearer the client presented *is* the nooticr
|
|
228
|
+
* credential. Deliberately not `createHttpTokenProvider`: that one falls back
|
|
229
|
+
* to the operator's environment and credentials file, which would answer one
|
|
230
|
+
* tenant's call with another's account.
|
|
231
|
+
*/
|
|
232
|
+
export function createApiKeyTokenProvider(apiKey) {
|
|
233
|
+
return { getAccessToken: async () => apiKey };
|
|
234
|
+
}
|
|
235
|
+
/** Builds a TokenProvider for HTTP mode: env > api key > file > per-session tokens. */
|
|
165
236
|
export function createHttpTokenProvider(auth, session) {
|
|
166
237
|
return {
|
|
167
238
|
getAccessToken: async () => auth.getAccessToken(session),
|
package/dist/config.js
CHANGED
|
@@ -40,6 +40,17 @@ export function getPort() {
|
|
|
40
40
|
}
|
|
41
41
|
return port;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* A nooticr API key from the environment, if one is set.
|
|
45
|
+
*
|
|
46
|
+
* This is the headless path: no browser, no credentials file, nothing to
|
|
47
|
+
* refresh. A server-side integration sets `NOOTICR_API_KEY` once and the key
|
|
48
|
+
* stays valid until someone revokes it.
|
|
49
|
+
*/
|
|
50
|
+
export function getApiKey() {
|
|
51
|
+
const raw = process.env.NOOTICR_API_KEY?.trim();
|
|
52
|
+
return raw ? raw : undefined;
|
|
53
|
+
}
|
|
43
54
|
export function getCredentialsFile() {
|
|
44
55
|
if (process.env.NOOTICR_CREDENTIALS_FILE) {
|
|
45
56
|
return process.env.NOOTICR_CREDENTIALS_FILE;
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Modes:
|
|
7
7
|
* nooticr-mcp stdio transport (default; Claude Desktop, Cursor)
|
|
8
|
-
* nooticr-mcp login browser-based Google sign-in
|
|
8
|
+
* nooticr-mcp login browser-based Google sign-in (or --api-key, no browser)
|
|
9
|
+
* nooticr-mcp api-key mint/list/revoke a key for a server with no browser
|
|
9
10
|
* nooticr-mcp --http remote HTTP transport with OAuth (OpenAI Agents SDK)
|
|
10
11
|
*/
|
|
11
12
|
import http from "node:http";
|
|
@@ -13,7 +14,8 @@ import { randomUUID } from "node:crypto";
|
|
|
13
14
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
15
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
15
16
|
import { getBaseUrl, getPublicUrl, getPort, getCredentialsFile, getTransportMode, DEFAULT_PORT, } from "./config.js";
|
|
16
|
-
import { AuthManager, NooticrAuthError, createHttpTokenProvider, createStdioTokenProvider } from "./auth.js";
|
|
17
|
+
import { AuthManager, NooticrAuthError, createApiKeyTokenProvider, createHttpTokenProvider, createManagementTokenProvider, createStdioTokenProvider, } from "./auth.js";
|
|
18
|
+
import { looksLikeApiKey } from "./shared/api-key.js";
|
|
17
19
|
import { NooticrClient, NooticrError } from "./nooticr.js";
|
|
18
20
|
import { OAuthManager, SCOPES } from "./oauth.js";
|
|
19
21
|
import { createMcpServer } from "./shared/tools.js";
|
|
@@ -78,12 +80,21 @@ function bearerToken(req) {
|
|
|
78
80
|
}
|
|
79
81
|
/**
|
|
80
82
|
* A request bearer token is valid when it was issued by our OAuth `/token`
|
|
81
|
-
* endpoint, or when it matches
|
|
82
|
-
* deployments and local testing — no
|
|
83
|
+
* endpoint, when it is a nooticr API key, or when it matches
|
|
84
|
+
* `NOOTICR_ACCESS_TOKEN` (pre-provisioned deployments and local testing — no
|
|
85
|
+
* browser OAuth round-trip needed).
|
|
86
|
+
*
|
|
87
|
+
* A key is accepted on its shape alone because this process does not hold the
|
|
88
|
+
* secret to check it against — only nooticr-server does, and it checks every
|
|
89
|
+
* call. What that admits without a round trip is `initialize` and
|
|
90
|
+
* `tools/list`, both of which answer the same for everyone; the first call
|
|
91
|
+
* that touches an account gets the backend's own 401.
|
|
83
92
|
*/
|
|
84
93
|
function validMcpToken(token, oauth) {
|
|
85
94
|
if (oauth.verifyToken(token))
|
|
86
95
|
return true;
|
|
96
|
+
if (looksLikeApiKey(token))
|
|
97
|
+
return true;
|
|
87
98
|
const envToken = process.env.NOOTICR_ACCESS_TOKEN;
|
|
88
99
|
return typeof envToken === "string" && envToken.length > 0 && token === envToken;
|
|
89
100
|
}
|
|
@@ -93,8 +104,10 @@ async function handleMcpRequest(state, req, res) {
|
|
|
93
104
|
if (!token || !validMcpToken(token, state.oauth)) {
|
|
94
105
|
return sendJson(res, 401, {
|
|
95
106
|
error: "Unauthorized",
|
|
96
|
-
error_description: "This MCP server requires
|
|
97
|
-
`${state.oauth.authorizationServerMetadata().authorization_endpoint}
|
|
107
|
+
error_description: "This MCP server requires authentication. Fetch an access token from " +
|
|
108
|
+
`${state.oauth.authorizationServerMetadata().authorization_endpoint}, or — on a ` +
|
|
109
|
+
"server with no browser — send a nooticr API key as the bearer token " +
|
|
110
|
+
"(npx @nooticr/mcp api-key create).",
|
|
98
111
|
});
|
|
99
112
|
}
|
|
100
113
|
// Hand the validated token to the SDK transport, which forwards it to tool
|
|
@@ -221,8 +234,16 @@ export async function runHttp(port, publicUrl) {
|
|
|
221
234
|
transports: new Map(),
|
|
222
235
|
connections: new Map(),
|
|
223
236
|
serverFactory: () => createMcpServer((extra) => {
|
|
224
|
-
const
|
|
225
|
-
|
|
237
|
+
const bearer = extra.authInfo?.token;
|
|
238
|
+
// A caller who presented their own API key is answered as themselves.
|
|
239
|
+
// Falling through to `createHttpTokenProvider` here would resolve the
|
|
240
|
+
// *operator's* environment or credentials file instead — one tenant's
|
|
241
|
+
// request served from another's account.
|
|
242
|
+
if (looksLikeApiKey(bearer)) {
|
|
243
|
+
return new NooticrClient(baseUrl, createApiKeyTokenProvider(bearer));
|
|
244
|
+
}
|
|
245
|
+
const session = bearer
|
|
246
|
+
? oauth.verifyToken(bearer)
|
|
226
247
|
: undefined;
|
|
227
248
|
return new NooticrClient(baseUrl, createHttpTokenProvider(auth, session
|
|
228
249
|
? {
|
|
@@ -253,6 +274,27 @@ export async function runLogin(opts) {
|
|
|
253
274
|
const client = new NooticrClient(baseUrl, {
|
|
254
275
|
getAccessToken: async () => undefined,
|
|
255
276
|
});
|
|
277
|
+
if (opts.apiKey) {
|
|
278
|
+
// Check the key before writing it. This is the one moment a person is
|
|
279
|
+
// present to fix a typo — an unchecked key would instead surface as a
|
|
280
|
+
// failed tool call later, from a client that cannot explain itself.
|
|
281
|
+
const keyed = new NooticrClient(baseUrl, createApiKeyTokenProvider(opts.apiKey));
|
|
282
|
+
let user;
|
|
283
|
+
try {
|
|
284
|
+
user = await keyed.me();
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
288
|
+
throw new NooticrAuthError(`That API key was rejected by ${baseUrl}: ${msg}\n` +
|
|
289
|
+
"`npx nooticr-mcp api-key list` (signed in) shows which keys are still active.");
|
|
290
|
+
}
|
|
291
|
+
await auth.persistApiKey(opts.apiKey, user);
|
|
292
|
+
process.stdout.write(`Signed in as ${user?.email ?? "unknown user"} with an API key. ` +
|
|
293
|
+
`Saved to ${auth.getCredentialsFile()}\n` +
|
|
294
|
+
"The key does not expire, so there is nothing to refresh and no browser to open again.\n" +
|
|
295
|
+
"On a server, prefer NOOTICR_API_KEY over this file — see the README.\n");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
256
298
|
if (opts.email && opts.password) {
|
|
257
299
|
const session = await client.login(opts.email, opts.password);
|
|
258
300
|
await auth.persistSession(session);
|
|
@@ -315,6 +357,98 @@ export async function runLogin(opts) {
|
|
|
315
357
|
process.exit(1);
|
|
316
358
|
}, 300_000).unref();
|
|
317
359
|
}
|
|
360
|
+
function formatDate(value) {
|
|
361
|
+
if (!value)
|
|
362
|
+
return "never";
|
|
363
|
+
const at = new Date(value);
|
|
364
|
+
return Number.isNaN(at.getTime()) ? value : at.toISOString().slice(0, 10);
|
|
365
|
+
}
|
|
366
|
+
function describeKey(key) {
|
|
367
|
+
const state = key.revokedAt
|
|
368
|
+
? "revoked"
|
|
369
|
+
: key.expiresAt && new Date(key.expiresAt).getTime() <= Date.now()
|
|
370
|
+
? "expired"
|
|
371
|
+
: "active";
|
|
372
|
+
return [
|
|
373
|
+
`${key.prefix}\u2026 ${key.name}`,
|
|
374
|
+
` id ${key.id}`,
|
|
375
|
+
` state ${state}`,
|
|
376
|
+
` created ${formatDate(key.createdAt)}`,
|
|
377
|
+
` last used ${formatDate(key.lastUsedAt)}`,
|
|
378
|
+
` expires ${formatDate(key.expiresAt)}`,
|
|
379
|
+
].join("\n");
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Mints, lists and revokes the keys a headless deployment authenticates with.
|
|
383
|
+
*
|
|
384
|
+
* Creating one needs an interactive session exactly once — that is the step it
|
|
385
|
+
* removes from every run afterwards — so these commands deliberately refuse to
|
|
386
|
+
* authenticate with `NOOTICR_API_KEY` even when one is set. The backend
|
|
387
|
+
* refuses key management to a key-authenticated caller anyway; resolving one
|
|
388
|
+
* here would only turn that bound into a 403 that reads like a bug.
|
|
389
|
+
*/
|
|
390
|
+
export async function runApiKey(opts) {
|
|
391
|
+
const baseUrl = getBaseUrl();
|
|
392
|
+
const auth = new AuthManager(baseUrl, getCredentialsFile());
|
|
393
|
+
const token = await auth.getAccessToken(undefined, { allowApiKey: false });
|
|
394
|
+
if (!token) {
|
|
395
|
+
// Telling these two apart matters: "sign in first" is wrong advice for
|
|
396
|
+
// someone who *is* signed in, with a key, and is being refused for a
|
|
397
|
+
// reason the message otherwise never mentions.
|
|
398
|
+
const hasKey = Boolean(await auth.getAccessToken());
|
|
399
|
+
throw new NooticrAuthError(hasKey
|
|
400
|
+
? "You are signed in with an API key, and a key cannot manage keys — that bound is " +
|
|
401
|
+
"what stops a leaked key minting a replacement that outlives revoking the original. " +
|
|
402
|
+
"Sign in as yourself for this one: `npx nooticr-mcp login` " +
|
|
403
|
+
"(or `login --email ... --password ...`)."
|
|
404
|
+
: "Sign in first: `npx nooticr-mcp login` (or `login --email ... --password ...`). " +
|
|
405
|
+
"Minting a key is the one step that needs a browser, and it is the last one: " +
|
|
406
|
+
"the key it returns is what every run afterwards uses.");
|
|
407
|
+
}
|
|
408
|
+
const client = new NooticrClient(baseUrl, createManagementTokenProvider(auth));
|
|
409
|
+
if (opts.action === "create") {
|
|
410
|
+
const created = await client.createApiKey({
|
|
411
|
+
...(opts.name !== undefined ? { name: opts.name } : {}),
|
|
412
|
+
...(opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId } : {}),
|
|
413
|
+
...(opts.expiresInDays !== undefined ? { expiresInDays: opts.expiresInDays } : {}),
|
|
414
|
+
});
|
|
415
|
+
if (opts.json) {
|
|
416
|
+
process.stdout.write(`${JSON.stringify(created, null, 2)}\n`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
process.stdout.write(`Created API key "${created.name}" (${created.id}).\n\n` +
|
|
420
|
+
` ${created.key}\n\n` +
|
|
421
|
+
"This is the only time the key is shown — nooticr stores a hash of it and\n" +
|
|
422
|
+
"cannot show it again. Store it wherever this deployment keeps its secrets,\n" +
|
|
423
|
+
"then give it to the server:\n\n" +
|
|
424
|
+
" NOOTICR_API_KEY=<key> npx nooticr-mcp\n\n" +
|
|
425
|
+
"or, against the remote HTTP endpoint, as the bearer token:\n\n" +
|
|
426
|
+
" Authorization: Bearer <key>\n");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (opts.action === "list") {
|
|
430
|
+
const keys = await client.listApiKeys();
|
|
431
|
+
if (opts.json) {
|
|
432
|
+
process.stdout.write(`${JSON.stringify(keys, null, 2)}\n`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (keys.length === 0) {
|
|
436
|
+
process.stdout.write("No API keys. Create one with `npx nooticr-mcp api-key create --name <name>`.\n");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
process.stdout.write(`${keys.map(describeKey).join("\n\n")}\n`);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (!opts.id) {
|
|
443
|
+
throw new NooticrError(400, "Usage: nooticr-mcp api-key revoke <id>");
|
|
444
|
+
}
|
|
445
|
+
await client.revokeApiKey(opts.id);
|
|
446
|
+
if (opts.json) {
|
|
447
|
+
process.stdout.write(`${JSON.stringify({ id: opts.id, revoked: true }, null, 2)}\n`);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
process.stdout.write(`Revoked ${opts.id}. Any server still presenting it now gets a 401.\n`);
|
|
451
|
+
}
|
|
318
452
|
// ---------------------------------------------------------------------------
|
|
319
453
|
// CLI
|
|
320
454
|
// ---------------------------------------------------------------------------
|
|
@@ -328,11 +462,18 @@ Usage:
|
|
|
328
462
|
(default port ${DEFAULT_PORT}; also NOOTICR_PORT)
|
|
329
463
|
nooticr-mcp login Sign in to nooticr via Google in your browser
|
|
330
464
|
nooticr-mcp login --email me@example.com --password '...' Password login
|
|
465
|
+
nooticr-mcp login --api-key nk_... Sign in with an API key (no browser)
|
|
466
|
+
nooticr-mcp api-key create [--name N] [--expires-in-days D] [--workspace-id W] [--json]
|
|
467
|
+
Mint a key for a server with no browser
|
|
468
|
+
nooticr-mcp api-key list [--json] List this account's keys
|
|
469
|
+
nooticr-mcp api-key revoke <id> [--json] Revoke one
|
|
331
470
|
nooticr-mcp --help Show this help
|
|
332
471
|
|
|
333
472
|
Environment variables:
|
|
334
473
|
NOOTICR_BASE_URL nooticr server base URL (default http://localhost:8080)
|
|
335
474
|
NOOTICR_ACCESS_TOKEN nooticr JWT access token (bypasses login)
|
|
475
|
+
NOOTICR_API_KEY nooticr API key from "api-key create" — no browser,
|
|
476
|
+
no refresh, valid until revoked
|
|
336
477
|
NOOTICR_CREDENTIALS_FILE token store path (default ~/.config/nooticr-mcp/credentials.json)
|
|
337
478
|
NOOTICR_PUBLIC_URL public base URL for the HTTP mode (default http://localhost:3457)
|
|
338
479
|
NOOTICR_PORT port for --http and login (default ${DEFAULT_PORT})
|
|
@@ -343,6 +484,11 @@ Client setup:
|
|
|
343
484
|
"command": "npx", "args": ["nooticr-mcp"] (plus NOOTICR_ACCESS_TOKEN if needed)
|
|
344
485
|
OpenAI Agents SDK (remote HTTP): use the RemoteMCPClient with URL
|
|
345
486
|
<NOOTICR_PUBLIC_URL>/mcp — the OAuth flow will open your browser.
|
|
487
|
+
Server-side, no browser: mint a key once with "api-key create", then either
|
|
488
|
+
set NOOTICR_API_KEY for the stdio server, or send it to the remote endpoint
|
|
489
|
+
as "Authorization: Bearer <key>" and skip the OAuth flow entirely.
|
|
490
|
+
A client that does not inherit your shell environment: "login --api-key nk_..."
|
|
491
|
+
stores the same key in the credentials file instead.
|
|
346
492
|
|
|
347
493
|
See README.md for full instructions.
|
|
348
494
|
`);
|
|
@@ -361,6 +507,17 @@ async function main() {
|
|
|
361
507
|
};
|
|
362
508
|
const email = valueOf("--email");
|
|
363
509
|
const password = valueOf("--password");
|
|
510
|
+
// Every other flag here is kebab-case; `--api_key` is the near-miss worth
|
|
511
|
+
// naming rather than ignoring, since ignoring it opens a browser instead.
|
|
512
|
+
if (rest.includes("--api_key")) {
|
|
513
|
+
process.stderr.write("Unknown flag --api_key. Did you mean --api-key?\n");
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
const apiKey = valueOf("--api-key");
|
|
517
|
+
if (rest.includes("--api-key") && !apiKey) {
|
|
518
|
+
process.stderr.write("--api-key needs a value (the nk_… key from `api-key create`).\n");
|
|
519
|
+
process.exit(1);
|
|
520
|
+
}
|
|
364
521
|
let port = getPort();
|
|
365
522
|
const portRaw = valueOf("--port");
|
|
366
523
|
if (portRaw !== undefined)
|
|
@@ -373,8 +530,12 @@ async function main() {
|
|
|
373
530
|
process.stderr.write("Both --email and --password must be provided together.\n");
|
|
374
531
|
process.exit(1);
|
|
375
532
|
}
|
|
533
|
+
if (apiKey && (email || password)) {
|
|
534
|
+
process.stderr.write("Use --api-key or --email/--password, not both.\n");
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
376
537
|
try {
|
|
377
|
-
await runLogin({ email, password, port });
|
|
538
|
+
await runLogin({ email, password, apiKey, port });
|
|
378
539
|
}
|
|
379
540
|
catch (err) {
|
|
380
541
|
process.stderr.write(`Login failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -382,6 +543,55 @@ async function main() {
|
|
|
382
543
|
}
|
|
383
544
|
return;
|
|
384
545
|
}
|
|
546
|
+
if (args[0] === "api-key" || args[0] === "api-keys") {
|
|
547
|
+
const rest = args.slice(1);
|
|
548
|
+
const action = rest[0];
|
|
549
|
+
if (action !== "create" && action !== "list" && action !== "revoke") {
|
|
550
|
+
process.stderr.write("Usage: nooticr-mcp api-key <create|list|revoke> [options]\n" +
|
|
551
|
+
" nooticr-mcp api-key create [--name N] [--expires-in-days D] [--workspace-id W]\n" +
|
|
552
|
+
" nooticr-mcp api-key revoke <id>\n");
|
|
553
|
+
process.exit(1);
|
|
554
|
+
}
|
|
555
|
+
const flags = rest.slice(1);
|
|
556
|
+
const valueOf = (flag) => {
|
|
557
|
+
const idx = flags.indexOf(flag);
|
|
558
|
+
return idx >= 0 && flags[idx + 1] ? flags[idx + 1] : undefined;
|
|
559
|
+
};
|
|
560
|
+
const opts = { action, json: flags.includes("--json") };
|
|
561
|
+
const name = valueOf("--name");
|
|
562
|
+
if (name !== undefined)
|
|
563
|
+
opts.name = name;
|
|
564
|
+
const workspaceId = valueOf("--workspace-id");
|
|
565
|
+
if (workspaceId !== undefined)
|
|
566
|
+
opts.workspaceId = workspaceId;
|
|
567
|
+
const expiresRaw = valueOf("--expires-in-days");
|
|
568
|
+
if (expiresRaw !== undefined) {
|
|
569
|
+
const days = Number.parseInt(expiresRaw, 10);
|
|
570
|
+
if (!Number.isInteger(days) || days < 1) {
|
|
571
|
+
process.stderr.write("--expires-in-days must be a positive whole number of days.\n");
|
|
572
|
+
process.exit(1);
|
|
573
|
+
}
|
|
574
|
+
opts.expiresInDays = days;
|
|
575
|
+
}
|
|
576
|
+
if (action === "revoke") {
|
|
577
|
+
// The id is positional, so anything that starts with "-" is a flag the
|
|
578
|
+
// user meant for something else, not the key they want gone.
|
|
579
|
+
const id = flags.find((arg) => !arg.startsWith("-"));
|
|
580
|
+
if (!id) {
|
|
581
|
+
process.stderr.write("Usage: nooticr-mcp api-key revoke <id>\n");
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
opts.id = id;
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
await runApiKey(opts);
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
385
595
|
const isHttp = args.includes("--http") || getTransportMode() === "http";
|
|
386
596
|
let port = getPort();
|
|
387
597
|
if (args.includes("--port")) {
|
package/dist/shared/amazon.js
CHANGED
|
@@ -33,7 +33,25 @@ export function normaliseProducts(raw) {
|
|
|
33
33
|
// raw `p.asin` there gave every review on every non-Amazon product the id
|
|
34
34
|
// "review::0", "review::1" — colliding across products, so a model citing
|
|
35
35
|
// one could not be pointed back at the right listing.
|
|
36
|
-
|
|
36
|
+
//
|
|
37
|
+
// This list must stay equal to `ID_KEYS` in nooticr-server's
|
|
38
|
+
// `crates/server/src/marketplace.rs`. The server computes `rollup.perProduct[].id`
|
|
39
|
+
// from its copy and the view joins those rows onto the tiles by the id
|
|
40
|
+
// resolved here, so a key in one list and not the other does not read as a
|
|
41
|
+
// missing id — it reads as a tile with no `negativeStarShare`, which looks
|
|
42
|
+
// like a site that publishes no histogram.
|
|
43
|
+
//
|
|
44
|
+
// `pid` and `goods_id` were the two missing, which is Flipkart and Temu:
|
|
45
|
+
// each collector publishes exactly one id field and those are theirs, so
|
|
46
|
+
// every product in a scan of either site resolved to "" and shared one
|
|
47
|
+
// blank key — the failure the comment below says was fixed for the others.
|
|
48
|
+
//
|
|
49
|
+
// Found by hand rather than by a test, and `??` is why it could be: it
|
|
50
|
+
// falls through null and undefined but not "", so a site that sends an
|
|
51
|
+
// empty id under an earlier key would stop the chain on the empty value
|
|
52
|
+
// rather than continue past it. Matching on a non-empty string closes
|
|
53
|
+
// both.
|
|
54
|
+
const id = String([p.asin, p.sku, p.pid, p.goods_id, p.item_id, p.product_id, p.id].find((v) => typeof v === "string" && v.trim() !== "") ?? "");
|
|
37
55
|
const histogram = (p.rating_histogram ?? {});
|
|
38
56
|
const reviews = Array.isArray(p.reviews) ? p.reviews : [];
|
|
39
57
|
const aspects = Array.isArray(p.review_aspects) ? p.review_aspects : [];
|
|
@@ -137,6 +155,26 @@ export function categoryGuidance(opts) {
|
|
|
137
155
|
: "."));
|
|
138
156
|
if (opts.brands.length)
|
|
139
157
|
lines.push(`Brands in the set: ${opts.brands.join(", ")}.`);
|
|
158
|
+
// Nothing came back, which is the one outcome where the next step is not
|
|
159
|
+
// "read the set" and the guidance above says nothing useful. Without this a
|
|
160
|
+
// model reports an empty category as a finding — the category is empty —
|
|
161
|
+
// when what actually happened is a search that found nothing, and the
|
|
162
|
+
// commonest reason is a query in a language that storefront does not sell
|
|
163
|
+
// in. Said here rather than only in the tool description because by the time
|
|
164
|
+
// this is read the description is long out of the model's attention.
|
|
165
|
+
if (opts.products === 0 && opts.complete) {
|
|
166
|
+
lines.push(`No listings came back. This is not a finding about the category — it is a search that ` +
|
|
167
|
+
`matched nothing. Before reporting it: check the query is in the language that ` +
|
|
168
|
+
`storefront sells in, try the common shopper's word for the thing rather than a ` +
|
|
169
|
+
`category or brand-family name, and drop any quotes or operators. If a second query ` +
|
|
170
|
+
`also returns nothing, say the search found nothing rather than that the category ` +
|
|
171
|
+
`is empty.`);
|
|
172
|
+
}
|
|
173
|
+
// Before the "still running" line and before the analysis prompt: a model
|
|
174
|
+
// that has already started composing a read is past the point where a
|
|
175
|
+
// caveat changes what it writes.
|
|
176
|
+
if (opts.freshness)
|
|
177
|
+
lines.push(opts.freshness);
|
|
140
178
|
if (!opts.complete) {
|
|
141
179
|
lines.push(`Collection is still running — ${opts.pending} listing${opts.pending === 1 ? "" : "s"} to go. ` +
|
|
142
180
|
`Everything below is real and usable now; call ${statusTool} with scanId "${opts.scanId}" ` +
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nooticr API keys — the credential for a caller with no browser.
|
|
3
|
+
*
|
|
4
|
+
* Everything else this package can authenticate with ends at a 15-minute JWT:
|
|
5
|
+
* `login` writes one (plus a refresh token) to a credentials file, and the
|
|
6
|
+
* OAuth flows mint one behind a consent screen. Both assume a machine someone
|
|
7
|
+
* can sit at once. A partner's backend cannot run either, so it gets a key
|
|
8
|
+
* instead: one string, set once, valid until it is revoked.
|
|
9
|
+
*
|
|
10
|
+
* The prefix is load-bearing in two places that must agree — this file and
|
|
11
|
+
* nooticr-server's `routes::api_keys::API_KEY_PREFIX`. The server uses it to
|
|
12
|
+
* decide whether a bearer token is a key at all (and therefore whether to look
|
|
13
|
+
* in its database); this package uses it to decide whether a token can be
|
|
14
|
+
* refreshed. A JWT is three base64url segments, so it can never collide.
|
|
15
|
+
*/
|
|
16
|
+
export const API_KEY_PREFIX = "nk_";
|
|
17
|
+
/** Whether a bearer token is a nooticr API key rather than a JWT. */
|
|
18
|
+
export function looksLikeApiKey(token) {
|
|
19
|
+
return typeof token === "string" && token.startsWith(API_KEY_PREFIX);
|
|
20
|
+
}
|
|
@@ -5,7 +5,7 @@ import { withEvidence } from "./evidence-digest.js";
|
|
|
5
5
|
import { confirmSpend, declinedResult } from "./spend.js";
|
|
6
6
|
import { allReviews, categoryGuidance, listingCount, normaliseProducts, scanCost } from "./amazon.js";
|
|
7
7
|
/**
|
|
8
|
-
* The
|
|
8
|
+
* The twelve sites, mirroring `MARKETPLACES` in nooticr-server.
|
|
9
9
|
*
|
|
10
10
|
* Kept in the same order and the same words deliberately: a model reads this
|
|
11
11
|
* text to decide what to send, and the server refuses on the same terms. Two
|
|
@@ -22,21 +22,25 @@ export const MARKETS = [
|
|
|
22
22
|
hint: "a country code or host — com (US), co.uk, de, fr, it, es, ca, co.jp, in, com.br, " +
|
|
23
23
|
"com.mx, com.au, nl, se, pl, com.be, com.tr, ae, sa, eg, sg, cn. Default www.amazon.com",
|
|
24
24
|
},
|
|
25
|
+
searchLang: "the storefront's own — English on com, co.uk, ca, in, com.au and sg, otherwise the country's",
|
|
25
26
|
},
|
|
26
27
|
{
|
|
27
28
|
slug: "aliexpress",
|
|
28
29
|
label: "AliExpress",
|
|
29
30
|
idLabel: "the numeric id from /item/<id>.html, or a full AliExpress product URL",
|
|
31
|
+
searchLang: "English",
|
|
30
32
|
},
|
|
31
33
|
{
|
|
32
34
|
slug: "cdiscount",
|
|
33
35
|
label: "Cdiscount",
|
|
34
36
|
idLabel: "the SKU from a product URL's /f-<category>-<sku>.html, or the full URL",
|
|
37
|
+
searchLang: "French",
|
|
35
38
|
},
|
|
36
39
|
{
|
|
37
40
|
slug: "flipkart",
|
|
38
41
|
label: "Flipkart",
|
|
39
42
|
idLabel: "the 16-character PID from a product URL's pid= parameter, or the full URL",
|
|
43
|
+
searchLang: "English",
|
|
40
44
|
},
|
|
41
45
|
{
|
|
42
46
|
slug: "mercadolibre",
|
|
@@ -48,6 +52,7 @@ export const MARKETS = [
|
|
|
48
52
|
"mercadolivre.com.br. Inferred from the ids when omitted, and a job runs against one " +
|
|
49
53
|
"market: an id from another is refused rather than mis-filed",
|
|
50
54
|
},
|
|
55
|
+
searchLang: "Spanish, except Brazil (MLB), which is Portuguese",
|
|
51
56
|
},
|
|
52
57
|
{
|
|
53
58
|
slug: "lazada",
|
|
@@ -59,6 +64,7 @@ export const MARKETS = [
|
|
|
59
64
|
hint: "sg, my, th, vn, ph or id. An item id is scoped to one of them — the same id on another " +
|
|
60
65
|
"Lazada host is a different product or nothing at all",
|
|
61
66
|
},
|
|
67
|
+
searchLang: "English on sg and ph; Thai, Vietnamese, Indonesian or Malay on th, vn, id and my",
|
|
62
68
|
},
|
|
63
69
|
{
|
|
64
70
|
slug: "otto",
|
|
@@ -70,6 +76,7 @@ export const MARKETS = [
|
|
|
70
76
|
hint: "de or at. The two are not interchangeable: an id from one does not address a product " +
|
|
71
77
|
"on the other",
|
|
72
78
|
},
|
|
79
|
+
searchLang: "German, on both storefronts",
|
|
73
80
|
},
|
|
74
81
|
{
|
|
75
82
|
slug: "rakuten",
|
|
@@ -81,6 +88,7 @@ export const MARKETS = [
|
|
|
81
88
|
hint: "jp (Ichiba) or tw. Taiwan publishes no review text, so a category study there reads on " +
|
|
82
89
|
"price and rating alone",
|
|
83
90
|
},
|
|
91
|
+
searchLang: "Japanese on jp, Traditional Chinese on tw",
|
|
84
92
|
},
|
|
85
93
|
{
|
|
86
94
|
slug: "trendyol",
|
|
@@ -91,6 +99,7 @@ export const MARKETS = [
|
|
|
91
99
|
hint: "one of 47 storefronts — tr, de, ae, sa, at, be, fr, it, nl, pl, ro and the rest. An id " +
|
|
92
100
|
"is scoped to one: the same number 404s on another country's",
|
|
93
101
|
},
|
|
102
|
+
searchLang: "Turkish on tr; the storefront's own language elsewhere",
|
|
94
103
|
},
|
|
95
104
|
{
|
|
96
105
|
slug: "jumia",
|
|
@@ -98,11 +107,30 @@ export const MARKETS = [
|
|
|
98
107
|
idLabel: "a SKU such as OR537EA86PWGTNAFAMZ. A product URL does NOT contain it — the number at the " +
|
|
99
108
|
"end of a product path is a different handle — so reach a product through `query` instead",
|
|
100
109
|
region: { field: "market", hint: "ng, eg, ke, ma, ci, gh, sn, tn, ug or dz" },
|
|
110
|
+
searchLang: "English on ng, ke, gh and ug; French on ci, sn, ma, tn and dz; Arabic or French on eg",
|
|
111
|
+
},
|
|
112
|
+
// The one resale site here. Its `rating` describes the seller rather than the
|
|
113
|
+
// product, because a Vinted listing is a unique second-hand item with no
|
|
114
|
+
// reviews of its own — the server says so in `ratingScope`/`ratingNote` on
|
|
115
|
+
// every scan of it, and those are worth passing on rather than summarising
|
|
116
|
+
// away.
|
|
117
|
+
{
|
|
118
|
+
slug: "vinted",
|
|
119
|
+
label: "Vinted",
|
|
120
|
+
idLabel: "the numeric id from /items/<id>, or a full Vinted item URL",
|
|
121
|
+
region: {
|
|
122
|
+
field: "market",
|
|
123
|
+
hint: "one of 27 European storefronts — fr, de, es, it, pl, co.uk, com (US), com.au and the " +
|
|
124
|
+
"rest. A listing belongs to exactly one: the same item id does not exist on another " +
|
|
125
|
+
"country's Vinted",
|
|
126
|
+
},
|
|
127
|
+
searchLang: "the storefront's own — French on fr, German on de, Polish on pl, and so on",
|
|
101
128
|
},
|
|
102
129
|
{
|
|
103
130
|
slug: "temu",
|
|
104
131
|
label: "Temu",
|
|
105
132
|
idLabel: "the goods id after -g- in a product URL, or the full URL",
|
|
133
|
+
searchLang: "English",
|
|
106
134
|
},
|
|
107
135
|
];
|
|
108
136
|
const BY_SLUG = new Map(MARKETS.map((m) => [m.slug, m]));
|
|
@@ -114,7 +142,7 @@ const SLUGS = MARKETS.map((m) => m.slug).join(", ");
|
|
|
114
142
|
/**
|
|
115
143
|
* The storefront argument, described site by site.
|
|
116
144
|
*
|
|
117
|
-
* One string rather than
|
|
145
|
+
* One string rather than twelve schemas, because a tool takes one `marketplace`
|
|
118
146
|
* per call and the host shows the whole description whichever it picks. Sites
|
|
119
147
|
* with a single storefront are named as taking none, so a model does not
|
|
120
148
|
* invent one for Temu.
|
|
@@ -124,6 +152,8 @@ const MARKET_HINT = MARKETS.filter((m) => m.region)
|
|
|
124
152
|
.join(" · ") +
|
|
125
153
|
` · ${MARKETS.filter((m) => !m.region).map((m) => m.label).join(", ")} have one storefront each ` +
|
|
126
154
|
"and take no market argument.";
|
|
155
|
+
/** Which language a query has to be in, site by site. */
|
|
156
|
+
const LANG_HINT = MARKETS.map((m) => `${m.label}: ${m.searchLang}`).join(" · ");
|
|
127
157
|
/** The product ids each site addresses by, for `items`. */
|
|
128
158
|
const ID_HINT = MARKETS.map((m) => `${m.label}: ${m.idLabel}`).join(" · ");
|
|
129
159
|
function structured(res) {
|
|
@@ -161,6 +191,9 @@ function marketplaceResult(payload, m, focus) {
|
|
|
161
191
|
scanId: String(payload.scanId ?? ""),
|
|
162
192
|
pending: Math.max(0, total - done),
|
|
163
193
|
focus,
|
|
194
|
+
// Present only when the server answered from its own store. See the field's
|
|
195
|
+
// comment in `categoryGuidance` for why it is repeated in both channels.
|
|
196
|
+
freshness: typeof payload.freshness === "string" ? payload.freshness : undefined,
|
|
164
197
|
site: m.label,
|
|
165
198
|
statusTool: "marketplace_scan_status",
|
|
166
199
|
// There is no free insights view for these sites yet, and pointing a model
|
|
@@ -177,6 +210,12 @@ function marketplaceResult(payload, m, focus) {
|
|
|
177
210
|
complete: payload.complete !== false,
|
|
178
211
|
query: payload.query ?? null,
|
|
179
212
|
market: payload.market ?? payload.domain ?? null,
|
|
213
|
+
// Where this answer came from, and when the figures in it were measured.
|
|
214
|
+
// `collectedAt` is when the result was assembled, which on a stored answer
|
|
215
|
+
// is now and says nothing about the price's age; `observedAt` is the age.
|
|
216
|
+
servedFrom: payload.servedFrom ?? "live",
|
|
217
|
+
observedAt: payload.observedAt ?? null,
|
|
218
|
+
freshness: payload.freshness ?? null,
|
|
180
219
|
progress,
|
|
181
220
|
rollup,
|
|
182
221
|
products,
|
|
@@ -197,7 +236,7 @@ export function registerMarketplaceTools(server, makeClient) {
|
|
|
197
236
|
server.registerTool("scan_marketplace_category", {
|
|
198
237
|
title: "Scan Marketplace Category",
|
|
199
238
|
_meta: viewMeta("scan_marketplace_category"),
|
|
200
|
-
description: "Collect a category from any of
|
|
239
|
+
description: "Collect a category from any of twelve marketplaces and read it: products with prices, " +
|
|
201
240
|
"star histograms, the review text itself, and the arithmetic over them (price spread, " +
|
|
202
241
|
"rating spread, which aspects recur across how many brands, and the share of each " +
|
|
203
242
|
"product's ratings sitting at 1-2 stars). `marketplace` picks the site — one of " +
|
|
@@ -222,7 +261,17 @@ export function registerMarketplaceTools(server, makeClient) {
|
|
|
222
261
|
inputSchema: z
|
|
223
262
|
.object({
|
|
224
263
|
marketplace: marketplaceArg,
|
|
225
|
-
query: z
|
|
264
|
+
query: z
|
|
265
|
+
.string()
|
|
266
|
+
.optional()
|
|
267
|
+
.describe("Category or keyword to search, e.g. 'cast iron skillet'. Two to four words, " +
|
|
268
|
+
"the common name a shopper would type rather than a catalogue description, " +
|
|
269
|
+
"and no quotes or AND/OR — none of these sites read them as operators, they " +
|
|
270
|
+
"search for them and narrow to nothing. It has to be in the storefront's " +
|
|
271
|
+
`language — ${LANG_HINT}. A query in the wrong language does not fail: it ` +
|
|
272
|
+
"returns a few imported listings, which reads as a thin category and is " +
|
|
273
|
+
"really a bad search. Brand names are not translated. A scan that came back " +
|
|
274
|
+
"with no products is a reason to change the query, not to raise the limit."),
|
|
226
275
|
items: z
|
|
227
276
|
.array(z.string())
|
|
228
277
|
.optional()
|
|
@@ -329,7 +378,7 @@ export function registerMarketplaceTools(server, makeClient) {
|
|
|
329
378
|
server.registerTool("get_marketplace_product", {
|
|
330
379
|
title: "Get Marketplace Product",
|
|
331
380
|
_meta: viewMeta("get_marketplace_product"),
|
|
332
|
-
description: "Fetch one product from any of
|
|
381
|
+
description: "Fetch one product from any of twelve marketplaces by id or URL: price, rating, the full " +
|
|
333
382
|
"star histogram, specs, and the review text. `marketplace` picks the site — one of " +
|
|
334
383
|
`${SLUGS}. ` +
|
|
335
384
|
"Costs 3 nooticr credits. " +
|
package/dist/shared/nooticr.js
CHANGED
|
@@ -98,11 +98,16 @@ export class NooticrClient {
|
|
|
98
98
|
body = undefined;
|
|
99
99
|
}
|
|
100
100
|
const json = (body ?? {});
|
|
101
|
+
// Name the endpoint: a bare status code says nothing about which call
|
|
102
|
+
// failed, and these surface to the user as tool errors. Then add the
|
|
103
|
+
// body when it is plainly a sentence — dropping it leaves a headless
|
|
104
|
+
// caller with a number and no cause.
|
|
105
|
+
const detail = plainTextDetail(text);
|
|
101
106
|
const errorMessage = typeof json.error === "string"
|
|
102
107
|
? json.error
|
|
103
|
-
:
|
|
104
|
-
|
|
105
|
-
`nooticr API error (${res.status}) from ${path}`;
|
|
108
|
+
: detail
|
|
109
|
+
? `nooticr API error (${res.status}) from ${path}: ${detail}`
|
|
110
|
+
: `nooticr API error (${res.status}) from ${path}`;
|
|
106
111
|
if (res.status >= 200 && res.status < 300) {
|
|
107
112
|
return body;
|
|
108
113
|
}
|
|
@@ -172,6 +177,28 @@ export class NooticrClient {
|
|
|
172
177
|
auth: true,
|
|
173
178
|
});
|
|
174
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Mints a key for the signed-in user. Deliberately not callable *with* a
|
|
182
|
+
* key: the backend refuses key-management to key-authenticated callers so a
|
|
183
|
+
* leaked key cannot mint a sibling that survives its own revocation.
|
|
184
|
+
*/
|
|
185
|
+
async createApiKey(input = {}) {
|
|
186
|
+
return this.request("POST", "/auth/api-keys", {
|
|
187
|
+
auth: true,
|
|
188
|
+
body: input,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async listApiKeys() {
|
|
192
|
+
const res = await this.request("GET", "/auth/api-keys", {
|
|
193
|
+
auth: true,
|
|
194
|
+
});
|
|
195
|
+
return res?.keys ?? [];
|
|
196
|
+
}
|
|
197
|
+
async revokeApiKey(id) {
|
|
198
|
+
await this.request("DELETE", `/auth/api-keys/${encodeURIComponent(id)}`, {
|
|
199
|
+
auth: true,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
175
202
|
async me() {
|
|
176
203
|
return this.request("GET", "/auth/me", { auth: true });
|
|
177
204
|
}
|
|
@@ -238,6 +265,21 @@ export class NooticrClient {
|
|
|
238
265
|
throw new NooticrError(res.status, typeof json.error === "string" ? json.error : `Refresh failed (${res.status})`, { body });
|
|
239
266
|
}
|
|
240
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* The backend answers a good many routes with a bare string rather than JSON
|
|
270
|
+
* (axum's `(StatusCode, String)`), and "nooticr API error (403)" tells whoever
|
|
271
|
+
* reads it nothing — least of all a headless integration, where the message is
|
|
272
|
+
* the only thing anyone will see. Use the body when it is plainly a sentence:
|
|
273
|
+
* short, one line, and not the start of a document.
|
|
274
|
+
*/
|
|
275
|
+
function plainTextDetail(text) {
|
|
276
|
+
const trimmed = text.trim();
|
|
277
|
+
if (!trimmed || trimmed.length > 300 || trimmed.includes("\n"))
|
|
278
|
+
return undefined;
|
|
279
|
+
if (/^[<{[]/.test(trimmed))
|
|
280
|
+
return undefined;
|
|
281
|
+
return trimmed;
|
|
282
|
+
}
|
|
241
283
|
async function parseJsonBody(res) {
|
|
242
284
|
const text = await res.text();
|
|
243
285
|
try {
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -435,8 +435,8 @@ export const TOOL_DEFINITIONS = [
|
|
|
435
435
|
{
|
|
436
436
|
name: "scan_marketplace_category",
|
|
437
437
|
title: "Scan Marketplace Category",
|
|
438
|
-
description: "Collect a category from any of
|
|
439
|
-
inputSchema: z.object({ marketplace: z.string().describe("Which site to read. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu."), query: z.string().optional().describe("Category or keyword to search, e.g. 'cast iron skillet'."), items: z.array(z.string()).optional().describe("Named competitors, by product id or URL."), limit: z.number().int().optional().describe("Products to collect (default 10, max 50)."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront to read, for the sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market."), waitSeconds: z.number().int().optional().describe("How long to wait before returning partial results (default 45, max 55). Poll marketplace_scan_status for the rest instead; it is free and repeatable."), focus: z.string().optional().describe("An extra question to answer from the reviews.") }).strict(),
|
|
438
|
+
description: "Collect a category from any of twelve marketplaces and read it: products with prices, star histograms, the review text itself, and the arithmetic over them (price spread, rating spread, which aspects recur across how many brands, and the share of each product's ratings sitting at 1-2 stars). marketplace picks the site — one of amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, vinted, temu. Pass query to search the category, items to pin named competitors by id or URL, or both. You write the read: purchase drivers, barriers, what the incumbents do well, the gaps, and how a new entrant could position. Collection runs in the background, so this returns everything ready within waitSeconds plus a scanId to continue with marketplace_scan_status — that is free, and repeating it is how a scan gets more time. For Amazon specifically, scan_amazon_category returns the same shape plus Amazon's own review-aspect counts. Consumes 3 nooticr credits per product collected — a product with reviews is a real browser render behind the site's bot defences.",
|
|
439
|
+
inputSchema: z.object({ marketplace: z.string().describe("Which site to read. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, vinted, temu."), query: z.string().optional().describe("Category or keyword to search, e.g. 'cast iron skillet'."), items: z.array(z.string()).optional().describe("Named competitors, by product id or URL."), limit: z.number().int().optional().describe("Products to collect (default 10, max 50)."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront to read, for the sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market."), waitSeconds: z.number().int().optional().describe("How long to wait before returning partial results (default 45, max 55). Poll marketplace_scan_status for the rest instead; it is free and repeatable."), focus: z.string().optional().describe("An extra question to answer from the reviews.") }).strict(),
|
|
440
440
|
},
|
|
441
441
|
{
|
|
442
442
|
name: "marketplace_scan_status",
|
|
@@ -447,8 +447,8 @@ export const TOOL_DEFINITIONS = [
|
|
|
447
447
|
{
|
|
448
448
|
name: "get_marketplace_product",
|
|
449
449
|
title: "Get Marketplace Product",
|
|
450
|
-
description: "Fetch one product from any of
|
|
451
|
-
inputSchema: z.object({ marketplace: z.string().describe("Which site the product is on. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, temu."), id: z.string().optional().describe("The product id, in whatever form the site addresses products by."), url: z.string().optional().describe("The full product URL, if you have that instead."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront the product is on, for sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market.") }).strict(),
|
|
450
|
+
description: "Fetch one product from any of twelve marketplaces by id or URL: price, rating, the full star histogram, specs, and the review text. marketplace picks the site — one of amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, vinted, temu. Use for a single product; for a category or a competitive set, scan_marketplace_category collects many in one job and rolls them up. Consumes 3 nooticr credits.",
|
|
451
|
+
inputSchema: z.object({ marketplace: z.string().describe("Which site the product is on. One of: amazon, aliexpress, cdiscount, flipkart, mercadolibre, lazada, otto, rakuten, trendyol, jumia, vinted, temu."), id: z.string().optional().describe("The product id, in whatever form the site addresses products by."), url: z.string().optional().describe("The full product URL, if you have that instead."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), market: z.string().optional().describe("The storefront the product is on, for sites that have more than one. Amazon: a country code or host — com (US), co.uk, de, fr and the rest, default www.amazon.com · Mercado Libre: one of eighteen markets, named however it comes to hand — MLB, br, Brazil · Lazada: sg, my, th, vn, ph or id · Otto: de or at · Rakuten: jp (Ichiba) or tw · Trendyol: one of 47 storefronts — tr, de, ae, sa and the rest · Jumia: ng, eg, ke, ma, ci, gh, sn, tn, ug or dz · AliExpress, Cdiscount, Flipkart and Temu have one storefront each and take no market argument. An id is scoped to one storefront: the same id on another is a different product or nothing at all."), domain: z.string().optional().describe("Amazon only: the marketplace host. Other sites use market.") }).strict(),
|
|
452
452
|
},
|
|
453
453
|
{
|
|
454
454
|
name: "show_amazon_category_insights",
|
package/dist/shared/tools.js
CHANGED
|
@@ -28,7 +28,7 @@ import { registerCollabTools } from "./collab.js";
|
|
|
28
28
|
import { registerAmazonTools } from "./amazon.js";
|
|
29
29
|
import { registerMarketplaceTools } from "./marketplace.js";
|
|
30
30
|
/** Current MCP server version — bumped on every deploy for traceability. */
|
|
31
|
-
export const MCP_SERVER_VERSION = "1.26.
|
|
31
|
+
export const MCP_SERVER_VERSION = "1.26.41";
|
|
32
32
|
/** MCP Apps extension identifier */
|
|
33
33
|
const UI_EXTENSION = "io.modelcontextprotocol/ui";
|
|
34
34
|
/** MIME type for MCP Apps HTML resources */
|
|
@@ -346,6 +346,24 @@ function toolError(prefix, err) {
|
|
|
346
346
|
// and what fixes it — including that they will not have to ask twice, which
|
|
347
347
|
// is only true now that nooticr_login resumes the interrupted call.
|
|
348
348
|
if (isAuthFailure(err)) {
|
|
349
|
+
// A deployment running on an API key has nobody to open a sign-in link:
|
|
350
|
+
// its 401 means the key was revoked, expired or mistyped, and "sign in
|
|
351
|
+
// again" sends whoever reads that log looking for a browser that does not
|
|
352
|
+
// exist. The backend names this case in its own message and the client
|
|
353
|
+
// carries plain-text errors through, so the two can be told apart here.
|
|
354
|
+
if (/api key/i.test(msg)) {
|
|
355
|
+
return {
|
|
356
|
+
content: [{
|
|
357
|
+
type: "text",
|
|
358
|
+
text: `${prefix}: this nooticr API key is not valid — revoked, expired, or mistyped. ` +
|
|
359
|
+
`No sign-in link will help: nothing here can be re-authorised interactively. ` +
|
|
360
|
+
`Replace the key (NOOTICR_API_KEY, or the bearer token sent to the remote ` +
|
|
361
|
+
`endpoint) with a live one — \`npx @nooticr/mcp api-key list\` shows which are ` +
|
|
362
|
+
`still active. (${msg})`,
|
|
363
|
+
}],
|
|
364
|
+
isError: true,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
349
367
|
return {
|
|
350
368
|
content: [{
|
|
351
369
|
type: "text",
|
|
@@ -3677,6 +3677,7 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
3677
3677
|
rakuten:{label:"Rakuten",color:"#BF0000",on:"#fff",path:"M23.277 21.3L3.939 24 .722 21.3h22.555zM7.6 19.276H3.939V0h6.052a6.653 6.653 0 0 1 6.65 6.646c0 2.234-1.108 4.204-2.799 5.418l5.418 7.211h-4.585l-4.486-5.979H7.6v5.98zm0-9.64h2.392a2.992 2.992 0 0 0 2.989-2.989 2.994 2.994 0 0 0-2.989-2.986H7.6v5.975z"},
|
|
3678
3678
|
trendyol:{label:"Trendyol",color:"#F27A1A",on:"#fff"},
|
|
3679
3679
|
jumia:{label:"Jumia",color:"#F68B1E",on:"#111"},
|
|
3680
|
+
vinted:{label:"Vinted",color:"#007782",on:"#fff"},
|
|
3680
3681
|
temu:{label:"Temu",color:"#FB7701",on:"#fff"}
|
|
3681
3682
|
};
|
|
3682
3683
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nooticr/mcp",
|
|
3
3
|
"mcpName": "com.nooticr/mcp",
|
|
4
|
-
"version": "1.26.
|
|
4
|
+
"version": "1.26.41",
|
|
5
5
|
"description": "MCP server for nooticr - fetch, discover and understand TikTok/Instagram/YouTube/X/Reddit/LinkedIn/Weibo posts with AI (media metadata, niche discovery, brand monitoring, hook & viral analysis)",
|
|
6
6
|
"homepage": "https://mcp.nooticr.com",
|
|
7
7
|
"repository": {
|