@nooticr/mcp 1.26.39 → 1.26.40

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 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 ten more sites — AliExpress, Cdiscount, Flipkart, Mercado Libre, Lazada, Otto, Rakuten, Trendyol, Jumia and Temu. `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. |
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 eleven sites, by id or URL: price, rating, the full star histogram, specs and the reviews. |
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
@@ -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
- const id = String(p.asin ?? p.id ?? p.item_id ?? p.sku ?? p.product_id ?? "");
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}" ` +
@@ -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 eleven sites, mirroring `MARKETPLACES` in nooticr-server.
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 eleven schemas, because a tool takes one `marketplace`
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 eleven marketplaces and read it: products with prices, " +
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.string().optional().describe("Category or keyword to search, e.g. 'cast iron skillet'."),
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 eleven marketplaces by id or URL: price, rating, the full " +
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. " +
@@ -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.39";
31
+ export const MCP_SERVER_VERSION = "1.26.40";
32
32
  /** MCP Apps extension identifier */
33
33
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
34
34
  /** MIME type for MCP Apps HTML resources */
@@ -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.39",
4
+ "version": "1.26.40",
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": {