@nooticr/mcp 1.26.44 → 1.26.46

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
@@ -52,7 +52,7 @@ npx @nooticr/mcp login # one-time sign-in (Google)
52
52
 
53
53
  ## Tools
54
54
 
55
- 85 tools, grouped by what you are trying to do. Prices are in nooticr credits and
55
+ 86 tools, grouped by what you are trying to do. Prices are in nooticr credits and
56
56
  match what the server actually charges.
57
57
 
58
58
  The job tools under **Answer a question you actually have** are not
@@ -152,6 +152,7 @@ a `scanId` for the rest.
152
152
  | `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. |
153
153
  | `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. |
154
154
  | `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. Given the `scanId`, it re-reads the listings from the scan itself (free) instead of drawing whatever the model re-sends, and flags any ASIN the read cites that the scan does not contain; without one, the card says its listings were not checked. |
155
+ | `show_marketplace_category_insights` | free | The same card for the other eleven marketplaces: your read beside that site's listings and reviews, labelled with the right site, re-read from the scan by `scanId` and `marketplace` so nothing is re-typed. |
155
156
 
156
157
  ### Make something
157
158
 
@@ -217,7 +217,8 @@ export function categoryGuidance(opts) {
217
217
  }
218
218
  if (insightsTool) {
219
219
  lines.push("");
220
- lines.push("To show the result in the conversation — products, their reviews, and your read side by", `side — call ${insightsTool} with what you concluded${opts.scanId ? ` and scanId "${opts.scanId}"` : " and the scanId"}.`, "It costs nothing, and it re-reads the listings from the scan itself, so do not re-send them.");
220
+ lines.push("To show the result in the conversation — products, their reviews, and your read side by", `side — call ${insightsTool} with what you concluded${opts.scanId ? ` and scanId "${opts.scanId}"` : " and the scanId"}` +
221
+ `${Object.entries(opts.statusArgs ?? {}).map(([k, v]) => `, ${k} "${v}"`).join("")}.`, "It costs nothing, and it re-reads the listings from the scan itself, so do not re-send them.");
221
222
  }
222
223
  return lines.join("\n");
223
224
  }
@@ -260,6 +261,76 @@ export const CREDITS_PER_LISTING = 3;
260
261
  export function scanCost(args) {
261
262
  return listingCount(args) * CREDITS_PER_LISTING;
262
263
  }
264
+ /**
265
+ * The read a model writes over a category scan, shared by the Amazon and
266
+ * the eleven-marketplace insights tools (#95): one shape, so the card and the
267
+ * guidance cannot drift between sites.
268
+ */
269
+ export const insightsInputShape = {
270
+ category: z.string().describe("The category this is a read of, e.g. 'Ashwagandha supplements'."),
271
+ summary: z.string().optional().describe("The category in two or three sentences."),
272
+ drivers: z
273
+ .array(z.object({
274
+ label: z.string().describe("The driver, in a few words."),
275
+ detail: z.string().optional().describe("What the reviews actually say."),
276
+ evidence: z
277
+ .array(z.string())
278
+ .optional()
279
+ .describe("Review ids or short quotes that show it."),
280
+ strength: z
281
+ .enum(["strong", "moderate", "thin"])
282
+ .optional()
283
+ .describe("How well the collected text supports it. Say `thin` rather than overstating."),
284
+ }))
285
+ .optional()
286
+ .describe("What makes someone buy in this category."),
287
+ barriers: z
288
+ .array(z.object({
289
+ label: z.string(),
290
+ detail: z.string().optional(),
291
+ evidence: z.array(z.string()).optional(),
292
+ strength: z.enum(["strong", "moderate", "thin"]).optional(),
293
+ }))
294
+ .optional()
295
+ .describe("What stops them buying, or makes them return it."),
296
+ strengths: z
297
+ .array(z.object({
298
+ brand: z.string(),
299
+ detail: z.string(),
300
+ asin: z
301
+ .string()
302
+ .optional()
303
+ .describe("The product id this is about: an ASIN on Amazon, the site's own id elsewhere."),
304
+ }))
305
+ .optional()
306
+ .describe("What each incumbent is genuinely doing well."),
307
+ gaps: z
308
+ .array(z.object({
309
+ label: z.string(),
310
+ detail: z.string().optional(),
311
+ evidence: z.array(z.string()).optional(),
312
+ }))
313
+ .optional()
314
+ .describe("What buyers keep asking for that nobody in the set serves."),
315
+ positioning: z
316
+ .array(z.object({
317
+ angle: z.string().describe("The claim, in one line."),
318
+ who: z.string().optional().describe("Who it is for."),
319
+ why: z.string().optional().describe("The driver it answers or the gap it fills."),
320
+ risk: z.string().optional().describe("What would have to be true, or what could go wrong."),
321
+ }))
322
+ .optional()
323
+ .describe("Concrete angles a new entrant could take."),
324
+ products: z
325
+ .array(anyObject())
326
+ .optional()
327
+ .describe("Only when there is no scanId. With one, the listings are re-read from the scan and anything here is ignored."),
328
+ rollup: anyObject().optional().describe("Only when there is no scanId; re-read from the scan otherwise."),
329
+ scanId: z
330
+ .string()
331
+ .optional()
332
+ .describe("The scan this reads. Pass it: it is what lets the card draw the collector's own listings."),
333
+ };
263
334
  export function registerAmazonTools(server, makeClient) {
264
335
  server.registerTool("scan_amazon_category", {
265
336
  title: "Scan Amazon Category",
@@ -454,105 +525,8 @@ export function registerAmazonTools(server, makeClient) {
454
525
  openWorldHint: false,
455
526
  },
456
527
  outputSchema: OUTPUT_SCHEMAS.show_amazon_category_insights,
457
- inputSchema: z
458
- .object({
459
- category: z.string().describe("The category this is a read of, e.g. 'Ashwagandha supplements'."),
460
- summary: z.string().optional().describe("The category in two or three sentences."),
461
- drivers: z
462
- .array(z.object({
463
- label: z.string().describe("The driver, in a few words."),
464
- detail: z.string().optional().describe("What the reviews actually say."),
465
- evidence: z
466
- .array(z.string())
467
- .optional()
468
- .describe("Review ids or short quotes that show it."),
469
- strength: z
470
- .enum(["strong", "moderate", "thin"])
471
- .optional()
472
- .describe("How well the collected text supports it. Say `thin` rather than overstating."),
473
- }))
474
- .optional()
475
- .describe("What makes someone buy in this category."),
476
- barriers: z
477
- .array(z.object({
478
- label: z.string(),
479
- detail: z.string().optional(),
480
- evidence: z.array(z.string()).optional(),
481
- strength: z.enum(["strong", "moderate", "thin"]).optional(),
482
- }))
483
- .optional()
484
- .describe("What stops them buying, or makes them return it."),
485
- strengths: z
486
- .array(z.object({
487
- brand: z.string(),
488
- detail: z.string(),
489
- asin: z.string().optional(),
490
- }))
491
- .optional()
492
- .describe("What each incumbent is genuinely doing well."),
493
- gaps: z
494
- .array(z.object({
495
- label: z.string(),
496
- detail: z.string().optional(),
497
- evidence: z.array(z.string()).optional(),
498
- }))
499
- .optional()
500
- .describe("What buyers keep asking for that nobody in the set serves."),
501
- positioning: z
502
- .array(z.object({
503
- angle: z.string().describe("The claim, in one line."),
504
- who: z.string().optional().describe("Who it is for."),
505
- why: z.string().optional().describe("The driver it answers or the gap it fills."),
506
- risk: z.string().optional().describe("What would have to be true, or what could go wrong."),
507
- }))
508
- .optional()
509
- .describe("Concrete angles a new entrant could take."),
510
- products: z
511
- .array(anyObject())
512
- .optional()
513
- .describe("Only when there is no scanId. With one, the listings are re-read from the scan and anything here is ignored."),
514
- rollup: anyObject().optional().describe("Only when there is no scanId; re-read from the scan otherwise."),
515
- scanId: z
516
- .string()
517
- .optional()
518
- .describe("The scan this reads. Pass it: it is what lets the card draw the collector's own listings."),
519
- })
520
- .passthrough(),
521
- }, async (args, extra) => {
522
- const checked = await rehydrate(args, () => makeClient({ ...extra, arguments: args }));
523
- const products = checked.products;
524
- const counts = {
525
- drivers: Array.isArray(args.drivers) ? args.drivers.length : 0,
526
- barriers: Array.isArray(args.barriers) ? args.barriers.length : 0,
527
- gaps: Array.isArray(args.gaps) ? args.gaps.length : 0,
528
- positioning: Array.isArray(args.positioning) ? args.positioning.length : 0,
529
- };
530
- const text = `Category read for ${String(args.category ?? "this category")}: ${counts.drivers} driver(s), ` +
531
- `${counts.barriers} barrier(s), ${counts.gaps} gap(s), ${counts.positioning} positioning angle(s)` +
532
- `${products.length ? `, drawn beside ${products.length} listing(s)` : ""}.` +
533
- (args.summary ? ` ${String(args.summary)}` : "") +
534
- ` ${checked.verification.note}`;
535
- return {
536
- content: [{ type: "text", text }],
537
- structuredContent: {
538
- marketplace: "amazon",
539
- view: "insights",
540
- category: String(args.category ?? ""),
541
- summary: args.summary ?? null,
542
- drivers: args.drivers ?? [],
543
- barriers: args.barriers ?? [],
544
- strengths: args.strengths ?? [],
545
- gaps: args.gaps ?? [],
546
- positioning: args.positioning ?? [],
547
- products,
548
- rollup: checked.rollup,
549
- scanId: args.scanId ?? null,
550
- verification: checked.verification,
551
- // The one read is amazon_scan_status, which is free by design.
552
- mcpCredits: { cost: 0 },
553
- },
554
- };
555
- });
528
+ inputSchema: z.object(insightsInputShape).passthrough(),
529
+ }, async (args, extra) => insightsResult(args, () => makeClient({ ...extra, arguments: args }), { marketplace: "amazon" }));
556
530
  }
557
531
  /**
558
532
  * The listings a category read is drawn beside, from the scan rather than
@@ -567,7 +541,7 @@ export function registerAmazonTools(server, makeClient) {
567
541
  * model sent is drawn and marked unverified rather than refused: a card with a
568
542
  * visible caveat is more use to a person than no card.
569
543
  */
570
- export async function rehydrate(args, client) {
544
+ export async function rehydrate(args, client, opts = {}) {
571
545
  const sent = Array.isArray(args.products) ? args.products : [];
572
546
  const sentRollup = (args.rollup ?? {});
573
547
  const scanId = typeof args.scanId === "string" ? args.scanId.trim() : "";
@@ -583,7 +557,11 @@ export async function rehydrate(args, client) {
583
557
  }
584
558
  let payload;
585
559
  try {
586
- const res = await (await client()).callTool("amazon_scan_status", { scanId, waitSeconds: 0 });
560
+ const res = await (await client()).callTool(opts.statusTool ?? "amazon_scan_status", {
561
+ ...(opts.statusArgs ?? {}),
562
+ scanId,
563
+ waitSeconds: 0,
564
+ });
587
565
  payload = structured(res);
588
566
  }
589
567
  catch (err) {
@@ -601,7 +579,7 @@ export async function rehydrate(args, client) {
601
579
  const scanComplete = payload.complete !== false;
602
580
  const flags = [
603
581
  notInScan.length ? `${notInScan.length} listing(s) you sent are not in the scan (${notInScan.join(", ")}) and are not drawn.` : "",
604
- unknownAsins.length ? `ASIN(s) cited in strengths but not in the scan: ${unknownAsins.join(", ")}.` : "",
582
+ unknownAsins.length ? `Product id(s) cited in strengths but not in the scan: ${unknownAsins.join(", ")}.` : "",
605
583
  scanComplete ? "" : "The scan is still running, so the set drawn is what it had collected so far.",
606
584
  ].filter(Boolean);
607
585
  return {
@@ -616,6 +594,48 @@ export async function rehydrate(args, client) {
616
594
  },
617
595
  };
618
596
  }
597
+ /**
598
+ * The insights card for any site: the model's read, drawn beside the scan's
599
+ * own listings, re-read by scanId so a mis-copied price or an invented
600
+ * competitor cannot reach the card (#107). `marketplace` is what the view
601
+ * labels the header with, so a Lazada read is never drawn as an Amazon one.
602
+ */
603
+ export async function insightsResult(args, client, site) {
604
+ const checked = await rehydrate(args, client, { statusTool: site.statusTool, statusArgs: site.statusArgs });
605
+ const products = checked.products;
606
+ const counts = {
607
+ drivers: Array.isArray(args.drivers) ? args.drivers.length : 0,
608
+ barriers: Array.isArray(args.barriers) ? args.barriers.length : 0,
609
+ gaps: Array.isArray(args.gaps) ? args.gaps.length : 0,
610
+ positioning: Array.isArray(args.positioning) ? args.positioning.length : 0,
611
+ };
612
+ const on = site.label ? ` on ${site.label}` : "";
613
+ const text = `Category read for ${String(args.category ?? "this category")}${on}: ${counts.drivers} driver(s), ` +
614
+ `${counts.barriers} barrier(s), ${counts.gaps} gap(s), ${counts.positioning} positioning angle(s)` +
615
+ `${products.length ? `, drawn beside ${products.length} listing(s)` : ""}.` +
616
+ (args.summary ? ` ${String(args.summary)}` : "") +
617
+ ` ${checked.verification.note}`;
618
+ return {
619
+ content: [{ type: "text", text }],
620
+ structuredContent: {
621
+ marketplace: site.marketplace,
622
+ view: "insights",
623
+ category: String(args.category ?? ""),
624
+ summary: args.summary ?? null,
625
+ drivers: args.drivers ?? [],
626
+ barriers: args.barriers ?? [],
627
+ strengths: args.strengths ?? [],
628
+ gaps: args.gaps ?? [],
629
+ positioning: args.positioning ?? [],
630
+ products,
631
+ rollup: checked.rollup,
632
+ scanId: args.scanId ?? null,
633
+ verification: checked.verification,
634
+ // The one read is the site's scan-status poll, free by design.
635
+ mcpCredits: { cost: 0 },
636
+ },
637
+ };
638
+ }
619
639
  /**
620
640
  * One scan result, shaped for both channels.
621
641
  *
@@ -0,0 +1,154 @@
1
+ const LIMIT = 2000;
2
+ const POST_KEYS = ["posts", "window", "demand", "supply", "results"];
3
+ /** One spelling per post: no trailing slash, no tracking query, YouTube's id kept. */
4
+ export function postKey(url) {
5
+ if (typeof url !== "string" || !url.trim())
6
+ return "";
7
+ const raw = url.trim();
8
+ try {
9
+ const u = new URL(raw);
10
+ const host = u.hostname.toLowerCase().replace(/^(www|m|mobile)\./, "");
11
+ const keep = host.endsWith("youtube.com") && u.searchParams.get("v") ? `?v=${u.searchParams.get("v")}` : "";
12
+ return `${host}${u.pathname.replace(/\/+$/, "")}${keep}`;
13
+ }
14
+ catch {
15
+ return raw.replace(/[?#].*$/, "").replace(/\/+$/, "").toLowerCase();
16
+ }
17
+ }
18
+ const urlsOf = (row) => [row.externalUrl, row.url, row.permalink, row.postUrl].map(postKey).filter(Boolean);
19
+ const handleOf = (row) => String(row.handle ?? row.username ?? row.creatorHandle ?? "")
20
+ .trim()
21
+ .replace(/^@/, "")
22
+ .toLowerCase();
23
+ export const creatorKey = (row) => {
24
+ const h = handleOf(row);
25
+ return h ? `${String(row.platform ?? "").toLowerCase()}:${h}` : "";
26
+ };
27
+ const runKey = (term, ranAt) => `${String(term ?? "").trim().toLowerCase()}|${String(ranAt ?? "")}`;
28
+ class Bounded {
29
+ map = new Map();
30
+ set(k, v) {
31
+ if (!k)
32
+ return;
33
+ this.map.delete(k);
34
+ this.map.set(k, v);
35
+ if (this.map.size > LIMIT)
36
+ this.map.delete(this.map.keys().next().value);
37
+ }
38
+ get(k) {
39
+ return k ? this.map.get(k) : undefined;
40
+ }
41
+ }
42
+ export class FetchLedger {
43
+ posts = new Bounded();
44
+ creators = new Bounded();
45
+ runs = new Bounded();
46
+ /** Keep what a tool returned. The views' own results are never kept: they echo the model. */
47
+ record(tool, structured) {
48
+ if (tool.startsWith("show_") || !structured || typeof structured !== "object")
49
+ return;
50
+ const sc = structured;
51
+ const keepPost = (p) => {
52
+ if (!p || typeof p !== "object")
53
+ return;
54
+ for (const k of urlsOf(p))
55
+ this.posts.set(k, p);
56
+ };
57
+ for (const key of POST_KEYS) {
58
+ if (Array.isArray(sc[key]))
59
+ for (const p of sc[key])
60
+ keepPost(p);
61
+ }
62
+ keepPost(sc.post);
63
+ // analyze_post and its siblings carry the post's fields at the top level.
64
+ if (urlsOf(sc).length && (sc.views != null || sc.caption != null || sc.title != null))
65
+ keepPost(sc);
66
+ if (Array.isArray(sc.creators)) {
67
+ for (const c of sc.creators) {
68
+ if (c && typeof c === "object")
69
+ this.creators.set(creatorKey(c), c);
70
+ }
71
+ }
72
+ if (Array.isArray(sc.runs)) {
73
+ for (const r of sc.runs) {
74
+ if (!r || typeof r !== "object" || r.ranAt == null)
75
+ continue;
76
+ this.runs.set(runKey(sc.term, r.ranAt), r);
77
+ this.runs.set(runKey("", r.ranAt), r);
78
+ }
79
+ }
80
+ }
81
+ post(url) {
82
+ return this.posts.get(postKey(url));
83
+ }
84
+ /**
85
+ * Posts as the view should draw them: nooticr's fields over the model's,
86
+ * so a copied stat can never win, and the model's extra fields survive.
87
+ */
88
+ checkPosts(sent) {
89
+ const unmatched = [];
90
+ const rows = sent.map((p) => {
91
+ const ours = urlsOf(p).map((k) => this.posts.get(k)).find(Boolean);
92
+ if (!ours) {
93
+ unmatched.push(String(p.externalUrl ?? p.url ?? "a post with no URL"));
94
+ return p;
95
+ }
96
+ return { ...p, ...ours, verified: true };
97
+ });
98
+ return { rows, verification: verdict(rows.length, unmatched, "post") };
99
+ }
100
+ checkCreators(sent) {
101
+ const unmatched = [];
102
+ const rows = sent.map((c) => {
103
+ const ours = this.creators.get(creatorKey(c));
104
+ if (!ours) {
105
+ unmatched.push(`@${handleOf(c) || "?"}${c.platform ? ` on ${String(c.platform)}` : ""}`);
106
+ return c;
107
+ }
108
+ // The order and any ranking note are the model's; every figure is ours.
109
+ return { ...c, ...ours, verified: true };
110
+ });
111
+ return { rows, verification: verdict(rows.length, unmatched, "creator") };
112
+ }
113
+ checkRuns(sent, term) {
114
+ const unmatched = [];
115
+ const rows = sent.map((r) => {
116
+ const ours = this.runs.get(runKey(term, r.ranAt)) ?? this.runs.get(runKey("", r.ranAt));
117
+ if (!ours) {
118
+ unmatched.push(String(r.ranAt ?? "an undated point"));
119
+ return r;
120
+ }
121
+ return { ...r, ...ours, verified: true };
122
+ });
123
+ return { rows, verification: verdict(rows.length, unmatched, "point") };
124
+ }
125
+ }
126
+ function verdict(total, unmatched, noun) {
127
+ if (!unmatched.length) {
128
+ return {
129
+ status: "verified",
130
+ note: `All ${total} ${noun}${total === 1 ? "" : "s"} drawn with the figures nooticr returned in this session.`,
131
+ unmatched,
132
+ };
133
+ }
134
+ const n = unmatched.length;
135
+ return {
136
+ status: "unverified",
137
+ note: `${n} of ${total} ${noun}${total === 1 ? "" : "s"} ${n === 1 ? "is" : "are"} not among what nooticr ` +
138
+ `returned in this session, so ${n === 1 ? "its" : "their"} figures are as re-sent and were not ` +
139
+ `checked: ${unmatched.slice(0, 5).join(", ")}${n > 5 ? ", …" : ""}.`,
140
+ unmatched,
141
+ };
142
+ }
143
+ /**
144
+ * One ledger per server. Keyed off the server object so the task path
145
+ * (registerSlowTool) and the plain path record into the same one without
146
+ * threading it through every registration.
147
+ */
148
+ const LEDGERS = new WeakMap();
149
+ export function ledgerFor(server) {
150
+ let l = LEDGERS.get(server);
151
+ if (!l)
152
+ LEDGERS.set(server, (l = new FetchLedger()));
153
+ return l;
154
+ }
@@ -353,6 +353,13 @@ export const LOADING_PLANS = {
353
353
  steps: [],
354
354
  free: true,
355
355
  },
356
+ show_marketplace_category_insights: {
357
+ label: "Drawing the category read",
358
+ kind: "list",
359
+ n: 5,
360
+ steps: [],
361
+ free: true,
362
+ },
356
363
  // ─── Marketplace ───
357
364
  //
358
365
  // The wait here is genuinely long and the reason is worth showing rather
@@ -3,7 +3,7 @@ import { OUTPUT_SCHEMAS } from "./output-schemas.js";
3
3
  import { viewMeta } from "./view-meta.js";
4
4
  import { withEvidence } from "./evidence-digest.js";
5
5
  import { confirmSpend, declinedResult } from "./spend.js";
6
- import { allReviews, categoryGuidance, listingCount, nextPoll, normaliseProducts, pollTail, scanCost } from "./amazon.js";
6
+ import { allReviews, categoryGuidance, insightsInputShape, insightsResult, listingCount, nextPoll, normaliseProducts, pollTail, scanCost, } from "./amazon.js";
7
7
  /**
8
8
  * The twelve sites, mirroring `MARKETPLACES` in nooticr-server.
9
9
  *
@@ -199,10 +199,10 @@ function marketplaceResult(payload, m, focus) {
199
199
  statusArgs: { marketplace: m.slug },
200
200
  status: typeof payload.status === "string" ? payload.status : undefined,
201
201
  total: num(progress.total) ?? undefined,
202
- // There is no free insights view for these sites yet, and pointing a model
203
- // at show_amazon_category_insights would have it draw a Lazada scan as an
204
- // Amazon one. Better to say nothing than to name the wrong tool.
205
- insightsTool: null,
202
+ // Its own view, labelled with this site (#95): pointing a model at
203
+ // show_amazon_category_insights would have it draw a Lazada scan as an
204
+ // Amazon one.
205
+ insightsTool: "show_marketplace_category_insights",
206
206
  });
207
207
  const structuredPayload = {
208
208
  guidance,
@@ -426,4 +426,48 @@ export function registerMarketplaceTools(server, makeClient) {
426
426
  return toolError("get_marketplace_product failed", err);
427
427
  }
428
428
  });
429
+ /**
430
+ * The read a model writes from scan_marketplace_category, drawn beside the
431
+ * site's own listings (#95). The same card and the same check against the
432
+ * scan as show_amazon_category_insights; the name shares whole words with
433
+ * its predecessor (`marketplace`, `category`) because that is what decides
434
+ * whether a host's tool search returns it.
435
+ */
436
+ server.registerTool("show_marketplace_category_insights", {
437
+ title: "Show Marketplace Category Insights",
438
+ _meta: viewMeta("show_marketplace_category_insights"),
439
+ description: "Display the category read you produced from scan_marketplace_category: purchase drivers, " +
440
+ "barriers, what each brand does well, the gaps and the positioning angles, drawn beside the " +
441
+ "site's listings and their reviews so a person can check any claim against the text it came " +
442
+ "from. Pass the same marketplace and the scanId: the listings are then re-read from the scan " +
443
+ "itself (free) rather than taken from you, so do not re-send them, and any product id you " +
444
+ "cite that the scan does not contain is flagged. Without a scanId, listings you pass are " +
445
+ "drawn marked as not checked against a scan. Free. Your read is attributed to you, never " +
446
+ "presented as a nooticr rating of anyone's product. For Amazon scans use " +
447
+ "show_amazon_category_insights. Call this after you have read the reviews, not instead of reading them.",
448
+ annotations: {
449
+ readOnlyHint: true,
450
+ destructiveHint: false,
451
+ idempotentHint: true,
452
+ // Its one request re-reads a scan the caller already ran, from
453
+ // nooticr's job store with no wait: nothing new is fetched.
454
+ openWorldHint: false,
455
+ },
456
+ outputSchema: OUTPUT_SCHEMAS.show_marketplace_category_insights,
457
+ inputSchema: z.object({ marketplace: marketplaceArg, ...insightsInputShape }).passthrough(),
458
+ }, async (args, extra) => {
459
+ const m = BY_SLUG.get(String(args.marketplace ?? ""));
460
+ if (!m) {
461
+ return {
462
+ content: [{ type: "text", text: `Unknown marketplace "${String(args.marketplace)}". One of: ${SLUGS}.` }],
463
+ isError: true,
464
+ };
465
+ }
466
+ return insightsResult(args, () => makeClient({ ...extra, arguments: args }), {
467
+ marketplace: m.slug,
468
+ label: m.label,
469
+ statusTool: "marketplace_scan_status",
470
+ statusArgs: { marketplace: m.slug },
471
+ });
472
+ });
429
473
  }
@@ -311,6 +311,38 @@ const marketplaceScanShape = {
311
311
  };
312
312
  const marketplaceScan = open(marketplaceScanShape);
313
313
  const amazonScan = open(amazonScanShape);
314
+ /** A category read drawn beside a scan's listings, on any site. */
315
+ const categoryInsights = open({
316
+ marketplace: scalar(),
317
+ view: scalar(),
318
+ category: scalar(),
319
+ summary: scalar(),
320
+ drivers: listOf(amazonFinding).describe("What makes someone buy, as you read it."),
321
+ barriers: listOf(amazonFinding).describe("What stops them, or makes them return it."),
322
+ strengths: listOf(open({ brand: scalar(), detail: scalar(), asin: scalar() })),
323
+ gaps: listOf(amazonFinding),
324
+ positioning: listOf(open({ angle: scalar(), who: scalar(), why: scalar(), risk: scalar() })),
325
+ products: listOf(amazonProduct).describe("The listings drawn: the scan's own, re-read by scanId, unless verification.status is unverified."),
326
+ rollup: open({}).nullish(),
327
+ scanId: scalar(),
328
+ verification: open({
329
+ status: scalar().describe("verified: the listings are the scan's own. unverified: as re-sent by the model."),
330
+ note: scalar(),
331
+ scanComplete: scalar(),
332
+ notInScan: listOf(z.string()).describe("Listings the model sent that the scan does not contain; not drawn."),
333
+ unknownAsins: listOf(z.string()).describe("ASINs cited in strengths that the scan does not contain."),
334
+ }).nullish(),
335
+ mcpCredits,
336
+ });
337
+ /**
338
+ * Whether a show_* view drew nooticr's own figures (#107): the session's
339
+ * fetch ledger overlays what was actually returned on what the model sent.
340
+ */
341
+ const checkedAgainstSession = open({
342
+ status: scalar().describe("verified: every row matched what nooticr returned this session. unverified: some are as re-sent."),
343
+ note: scalar(),
344
+ unmatched: listOf(z.string()).describe("Rows not among what nooticr returned in this session; drawn as sent, unchecked."),
345
+ }).nullish();
314
346
  export const OUTPUT_SCHEMAS = {
315
347
  analyze_post: open({ ...analyzed, ...evidence }),
316
348
  analyze_post_fast: open({ ...evidence, ...analyzed }),
@@ -539,6 +571,7 @@ export const OUTPUT_SCHEMAS = {
539
571
  // to land except chat text. Free, and make no requests, same as
540
572
  // show_comment_review: they only draw what they're handed.
541
573
  show_compared_posts: open({
574
+ verification: checkedAgainstSession,
542
575
  posts: listOf(post).describe("The 2-5 posts being compared, same shape as compare_posts returned."),
543
576
  comparison: open({
544
577
  winner: scalar().describe("1-indexed position of the post that won, matching the posts array."),
@@ -550,6 +583,7 @@ export const OUTPUT_SCHEMAS = {
550
583
  mcpCredits,
551
584
  }),
552
585
  show_post_analysis: open({
586
+ verification: checkedAgainstSession,
553
587
  url: scalar(),
554
588
  post: post.nullish().describe("The post analyze_post/analyze_post_fast/understand_social_post handed back."),
555
589
  analysis: open({}).passthrough().nullish().describe("Your own analysis, any of the fields analyze_post's guidance asked for — summary, " +
@@ -569,6 +603,7 @@ export const OUTPUT_SCHEMAS = {
569
603
  mcpCredits,
570
604
  }),
571
605
  show_variants: open({
606
+ verification: checkedAgainstSession,
572
607
  sourceUrl: scalar(),
573
608
  post: post.nullish(),
574
609
  variants: listOf(open({
@@ -945,6 +980,7 @@ export const OUTPUT_SCHEMAS = {
945
980
  mcpCredits,
946
981
  }),
947
982
  show_standings: open({
983
+ verification: checkedAgainstSession,
948
984
  creators: listOf(anyObject()),
949
985
  metric: scalar(),
950
986
  ranking: scalar().describe("Which axis the order is on — 'how often' and 'how big' disagree."),
@@ -1148,6 +1184,7 @@ export const OUTPUT_SCHEMAS = {
1148
1184
  * might want is left underived on purpose — the model reads the series.
1149
1185
  */
1150
1186
  show_trend: open({
1187
+ verification: checkedAgainstSession,
1151
1188
  points: listOf(anyObject()),
1152
1189
  term: scalar(),
1153
1190
  metric: scalar(),
@@ -1542,26 +1579,7 @@ export const OUTPUT_SCHEMAS = {
1542
1579
  ...amazonScanShape,
1543
1580
  product: amazonProduct.nullish().describe("The single listing, also present as products[0]."),
1544
1581
  }),
1545
- show_amazon_category_insights: open({
1546
- marketplace: scalar(),
1547
- view: scalar(),
1548
- category: scalar(),
1549
- summary: scalar(),
1550
- drivers: listOf(amazonFinding).describe("What makes someone buy, as you read it."),
1551
- barriers: listOf(amazonFinding).describe("What stops them, or makes them return it."),
1552
- strengths: listOf(open({ brand: scalar(), detail: scalar(), asin: scalar() })),
1553
- gaps: listOf(amazonFinding),
1554
- positioning: listOf(open({ angle: scalar(), who: scalar(), why: scalar(), risk: scalar() })),
1555
- products: listOf(amazonProduct).describe("The listings drawn: the scan's own, re-read by scanId, unless verification.status is unverified."),
1556
- rollup: open({}).nullish(),
1557
- scanId: scalar(),
1558
- verification: open({
1559
- status: scalar().describe("verified: the listings are the scan's own. unverified: as re-sent by the model."),
1560
- note: scalar(),
1561
- scanComplete: scalar(),
1562
- notInScan: listOf(z.string()).describe("Listings the model sent that the scan does not contain; not drawn."),
1563
- unknownAsins: listOf(z.string()).describe("ASINs cited in strengths that the scan does not contain."),
1564
- }).nullish(),
1565
- mcpCredits,
1566
- }),
1582
+ show_amazon_category_insights: categoryInsights,
1583
+ // The same card for the other eleven sites (#95); `marketplace` says which.
1584
+ show_marketplace_category_insights: categoryInsights,
1567
1585
  };
@@ -24,6 +24,7 @@
24
24
  * The task APIs are experimental in SDK 1.30 and may change under us. They are
25
25
  * contained here for that reason — one file to fix rather than three handlers.
26
26
  */
27
+ import { ledgerFor } from "./fetch-ledger.js";
27
28
  import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
28
29
  /** How long a finished result stays fetchable. */
29
30
  export const TASK_TTL_MS = 900_000;
@@ -67,6 +68,7 @@ export function registerSlowTool(server, name, config, run) {
67
68
  void (async () => {
68
69
  try {
69
70
  const result = await run(args, extra);
71
+ ledgerFor(server).record(name, result?.structuredContent);
70
72
  await extra.taskStore.storeTaskResult(task.taskId, result?.isError ? "failed" : "completed", result);
71
73
  }
72
74
  catch (err) {
@@ -510,4 +510,10 @@ export const TOOL_DEFINITIONS = [
510
510
  description: "Display the category read you produced from scan_amazon_category: purchase drivers, barriers, what each brand does well, the gaps, and the positioning angles \u2014 drawn beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. Pass the scanId: the listings are then re-read from the scan itself (free) rather than taken from you, and any ASIN you cite that the scan does not contain is flagged. Without a scanId, listings you pass are drawn marked as not checked against a scan. Free. Your read is attributed to you rather than presented as a nooticr rating of anyone's product. Call this after you have read the reviews, not instead of reading them.",
511
511
  inputSchema: z.object({ category: z.string().describe("The category this is a read of."), summary: z.string().optional(), drivers: z.array(z.any()).optional().describe("What makes someone buy, each with the review evidence behind it."), barriers: z.array(z.any()).optional().describe("What stops them buying, or makes them return it."), strengths: z.array(z.any()).optional().describe("What each incumbent does well."), gaps: z.array(z.any()).optional().describe("What buyers keep asking for that nobody serves."), positioning: z.array(z.any()).optional().describe("Angles a new entrant could take."), products: z.array(z.any()).optional().describe("The listings from the scan, passed straight through."), rollup: z.record(z.unknown()).optional().describe("The rollup from the scan, passed straight through."), scanId: z.string().optional() }).passthrough(),
512
512
  },
513
+ {
514
+ name: "show_marketplace_category_insights",
515
+ title: "Show Marketplace Category Insights",
516
+ description: "Display the category read you produced from scan_marketplace_category: purchase drivers, barriers, what each brand does well, the gaps, and the positioning angles \u2014 drawn beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. Pass the scanId: the listings are then re-read from the scan itself (free) rather than taken from you, and any ASIN you cite that the scan does not contain is flagged. Without a scanId, listings you pass are drawn marked as not checked against a scan. Free. Your read is attributed to you rather than presented as a nooticr rating of anyone's product. Call this after you have read the reviews, not instead of reading them.",
517
+ inputSchema: z.object({ marketplace: z.string().describe("The site the scan ran on, e.g. lazada."), category: z.string().describe("The category this is a read of."), summary: z.string().optional(), drivers: z.array(z.any()).optional().describe("What makes someone buy, each with the review evidence behind it."), barriers: z.array(z.any()).optional().describe("What stops them buying, or makes them return it."), strengths: z.array(z.any()).optional().describe("What each incumbent does well."), gaps: z.array(z.any()).optional().describe("What buyers keep asking for that nobody serves."), positioning: z.array(z.any()).optional().describe("Angles a new entrant could take."), products: z.array(z.any()).optional().describe("The listings from the scan, passed straight through."), rollup: z.record(z.unknown()).optional().describe("The rollup from the scan, passed straight through."), scanId: z.string().optional() }).passthrough(),
518
+ },
513
519
  ];
@@ -5,6 +5,7 @@
5
5
  * file for the CLI, KV session for the worker) — the tool bodies, schemas and
6
6
  * result formatting live here and nowhere else.
7
7
  */
8
+ import { ledgerFor } from "./fetch-ledger.js";
8
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
10
  import { z } from "zod";
10
11
  import { NooticrError, safeCreditsHint } from "./nooticr.js";
@@ -29,7 +30,7 @@ import { registerCollabTools } from "./collab.js";
29
30
  import { registerAmazonTools } from "./amazon.js";
30
31
  import { registerMarketplaceTools } from "./marketplace.js";
31
32
  /** Current MCP server version — bumped on every deploy for traceability. */
32
- export const MCP_SERVER_VERSION = "1.26.44";
33
+ export const MCP_SERVER_VERSION = "1.26.46";
33
34
  /** MCP Apps extension identifier */
34
35
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
35
36
  /** MIME type for MCP Apps HTML resources */
@@ -556,6 +557,18 @@ export function createMcpServer(rawMakeClient, opts) {
556
557
  },
557
558
  },
558
559
  });
560
+ // Every tool result passes through the session's fetch ledger on its way
561
+ // out, so the show_* views can draw what nooticr returned rather than the
562
+ // model's copy of it (#107). Wrapped once here rather than in eighty
563
+ // handlers; registerSlowTool records the task path the same way.
564
+ const ledger = ledgerFor(server);
565
+ const registerTool = server.registerTool.bind(server);
566
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
567
+ server.registerTool = (name, config, handler) => registerTool(name, config, async (...a) => {
568
+ const result = (await handler(...a));
569
+ ledger.record(name, result?.structuredContent);
570
+ return result;
571
+ });
559
572
  // Register one UI app resource per tool/view. Claude/ChatGPT render a
560
573
  // separate sandboxed app per resourceUri and key app state by it, so a
561
574
  // distinct URI per tool avoids a shared app instance/session colliding
@@ -681,6 +694,7 @@ export function createMcpServer(rawMakeClient, opts) {
681
694
  "amazon_scan_status",
682
695
  "get_amazon_product",
683
696
  "show_amazon_category_insights",
697
+ "show_marketplace_category_insights",
684
698
  // The marketplace-agnostic trio. Every name here gets a ui://nooticr/<tool>
685
699
  // resource, which is what gives a tool its card — a tool missing from this
686
700
  // list is one the host is told to render and then 404s on.
@@ -1685,7 +1699,9 @@ export function createMcpServer(rawMakeClient, opts) {
1685
1699
  // it (show_hooks, show_variants, show_repurposed_post).
1686
1700
  server.registerTool("show_trend", {
1687
1701
  title: "Show Trend",
1688
- description: "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what " +
1702
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1703
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1704
+ "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what " +
1689
1705
  "you pass it: the series as a chart, per-network lines where you supply them, and your own " +
1690
1706
  "read of what changed. Pass `tooShort` when the series has too few points to call a " +
1691
1707
  "direction, and `edgeIsRecordStart` when the left edge is where the record begins rather " +
@@ -1726,16 +1742,19 @@ export function createMcpServer(rawMakeClient, opts) {
1726
1742
  })
1727
1743
  .strict(),
1728
1744
  }, async (args) => {
1745
+ const checked = ledger.checkRuns(args.points, args.term);
1729
1746
  return {
1730
1747
  content: [
1731
1748
  {
1732
1749
  type: "text",
1733
1750
  text: `Showing a ${args.points.length}-point trend${args.term ? ` for "${args.term}"` : ""}.` +
1734
- (args.verdict ? ` ${args.verdict}` : ""),
1751
+ (args.verdict ? ` ${args.verdict}` : "") +
1752
+ ` ${checked.verification.note}`,
1735
1753
  },
1736
1754
  ],
1737
1755
  structuredContent: {
1738
- points: args.points,
1756
+ points: checked.rows,
1757
+ verification: checked.verification,
1739
1758
  term: args.term ?? null,
1740
1759
  metric: args.metric ?? "found",
1741
1760
  verdict: args.verdict ?? null,
@@ -1747,7 +1766,9 @@ export function createMcpServer(rawMakeClient, opts) {
1747
1766
  });
1748
1767
  server.registerTool("show_standings", {
1749
1768
  title: "Show Standings",
1750
- description: "Draw the standings you read out of compare_creators or watchlist_standings. Free, and makes " +
1769
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1770
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1771
+ "Draw the standings you read out of compare_creators or watchlist_standings. Free, and makes " +
1751
1772
  "no requests — it only renders what you pass it: one row per creator with their window, their " +
1752
1773
  "own median, how often they beat it and how hard, and the post that did best. Pass `ranking` " +
1753
1774
  "to say which axis you ordered on, and `tooThin` for the creators whose window was too short " +
@@ -1783,16 +1804,19 @@ export function createMcpServer(rawMakeClient, opts) {
1783
1804
  })
1784
1805
  .strict(),
1785
1806
  }, async (args) => {
1807
+ const checked = ledger.checkCreators(args.creators);
1786
1808
  return {
1787
1809
  content: [
1788
1810
  {
1789
1811
  type: "text",
1790
1812
  text: `Showing standings for ${args.creators.length} creator${args.creators.length === 1 ? "" : "s"}.` +
1791
- (args.verdict ? ` ${args.verdict}` : ""),
1813
+ (args.verdict ? ` ${args.verdict}` : "") +
1814
+ ` ${checked.verification.note}`,
1792
1815
  },
1793
1816
  ],
1794
1817
  structuredContent: {
1795
- creators: args.creators,
1818
+ creators: checked.rows,
1819
+ verification: checked.verification,
1796
1820
  metric: args.metric ?? "views",
1797
1821
  ranking: args.ranking ?? null,
1798
1822
  verdict: args.verdict ?? null,
@@ -1803,7 +1827,9 @@ export function createMcpServer(rawMakeClient, opts) {
1803
1827
  });
1804
1828
  server.registerTool("show_compared_posts", {
1805
1829
  title: "Show Comparison",
1806
- description: "Display a comparison you wrote after compare_posts fetched the first post and you fetched " +
1830
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1831
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1832
+ "Display a comparison you wrote after compare_posts fetched the first post and you fetched " +
1807
1833
  "the rest yourself (get_social_media, 1 credit each). Free, and makes no requests — it only " +
1808
1834
  "draws what you pass it: each post with a BEST badge on the winner, what differed, shared " +
1809
1835
  "strengths and the next experiment worth running. Call this after you have done the " +
@@ -1834,15 +1860,18 @@ export function createMcpServer(rawMakeClient, opts) {
1834
1860
  })
1835
1861
  .strict(),
1836
1862
  }, async (args) => {
1863
+ const checked = ledger.checkPosts(args.posts);
1837
1864
  return {
1838
1865
  content: [
1839
1866
  {
1840
1867
  type: "text",
1841
- text: `Showing a comparison of ${args.posts.length} posts.${args.winnerReason ? ` ${args.winnerReason}` : ""}`,
1868
+ text: `Showing a comparison of ${args.posts.length} posts.${args.winnerReason ? ` ${args.winnerReason}` : ""}` +
1869
+ ` ${checked.verification.note}`,
1842
1870
  },
1843
1871
  ],
1844
1872
  structuredContent: {
1845
- posts: args.posts,
1873
+ posts: checked.rows,
1874
+ verification: checked.verification,
1846
1875
  comparison: {
1847
1876
  winner: args.winner,
1848
1877
  winnerReason: args.winnerReason ?? null,
@@ -1856,7 +1885,9 @@ export function createMcpServer(rawMakeClient, opts) {
1856
1885
  });
1857
1886
  server.registerTool("show_post_analysis", {
1858
1887
  title: "Show Analysis",
1859
- description: "Display an analysis you wrote after analyze_post, analyze_post_fast or understand_social_post " +
1888
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1889
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1890
+ "Display an analysis you wrote after analyze_post, analyze_post_fast or understand_social_post " +
1860
1891
  "handed you the material. Free, and makes no requests — it only draws what you pass it: hook " +
1861
1892
  "strength, script structure, quotable lines, hashtags, target audience, viral triggers and " +
1862
1893
  "more, whichever of these you actually produced. Call this after you have done the analysing, " +
@@ -1883,11 +1914,15 @@ export function createMcpServer(rawMakeClient, opts) {
1883
1914
  })
1884
1915
  .strict(),
1885
1916
  }, async (args) => {
1917
+ const checked = ledger.checkPosts([
1918
+ { ...(args.post ?? { platform: platformFromUrl(args.url) }), externalUrl: args.post?.externalUrl ?? args.url },
1919
+ ]);
1886
1920
  return {
1887
- content: [{ type: "text", text: `Showing your analysis of ${args.url}.` }],
1921
+ content: [{ type: "text", text: `Showing your analysis of ${args.url}. ${checked.verification.note}` }],
1888
1922
  structuredContent: {
1889
1923
  url: args.url,
1890
- post: args.post ?? { platform: platformFromUrl(args.url), externalUrl: args.url },
1924
+ post: checked.rows[0],
1925
+ verification: checked.verification,
1891
1926
  analysis: args.analysis,
1892
1927
  mcpCredits: { cost: 0 },
1893
1928
  },
@@ -1932,7 +1967,9 @@ export function createMcpServer(rawMakeClient, opts) {
1932
1967
  });
1933
1968
  server.registerTool("show_variants", {
1934
1969
  title: "Show Variants",
1935
- description: "Display the post variants you wrote after create_variants handed you the original post's " +
1970
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1971
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1972
+ "Display the post variants you wrote after create_variants handed you the original post's " +
1936
1973
  "material. Free, and makes no requests — it only draws what you pass it: each variant's hook, " +
1937
1974
  "the angle that changes, its shot beats and its call to action. Call this after you have " +
1938
1975
  "written the variants, not instead of writing them.",
@@ -1960,11 +1997,23 @@ export function createMcpServer(rawMakeClient, opts) {
1960
1997
  })
1961
1998
  .strict(),
1962
1999
  }, async (args) => {
2000
+ const checked = ledger.checkPosts([
2001
+ {
2002
+ ...(args.post ?? { platform: platformFromUrl(args.sourceUrl) }),
2003
+ externalUrl: args.post?.externalUrl ?? args.sourceUrl,
2004
+ },
2005
+ ]);
1963
2006
  return {
1964
- content: [{ type: "text", text: `Showing ${args.variants.length} variants of ${args.sourceUrl}.` }],
2007
+ content: [
2008
+ {
2009
+ type: "text",
2010
+ text: `Showing ${args.variants.length} variants of ${args.sourceUrl}. ${checked.verification.note}`,
2011
+ },
2012
+ ],
1965
2013
  structuredContent: {
1966
2014
  sourceUrl: args.sourceUrl,
1967
- post: args.post ?? { platform: platformFromUrl(args.sourceUrl), externalUrl: args.sourceUrl },
2015
+ post: checked.rows[0],
2016
+ verification: checked.verification,
1968
2017
  variants: args.variants,
1969
2018
  mcpCredits: { cost: 0 },
1970
2019
  },
@@ -66,7 +66,8 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
66
66
  get_amazon_product:"Get Amazon Product",
67
67
  scan_marketplace_category:"Scan Marketplace Category",
68
68
  marketplace_scan_status:"Marketplace Scan Status",
69
- get_marketplace_product:"Get Marketplace Product"
69
+ get_marketplace_product:"Get Marketplace Product",
70
+ show_marketplace_category_insights:"Marketplace Category Insights"
70
71
  };
71
72
  var TOOL_DESCS={
72
73
  analyze_post:"Frames from the post plus its transcript, for you to read yourself.",
@@ -102,9 +103,10 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
102
103
  scan_amazon_category:"A category on Amazon — prices, ratings and what reviewers keep raising.",
103
104
  amazon_scan_status:"The listings an Amazon scan has collected so far.",
104
105
  get_amazon_product:"One Amazon listing: price, rating, star histogram and reviews.",
105
- scan_marketplace_category:"A category on any of eleven marketplaces — prices, ratings and review themes.",
106
+ scan_marketplace_category:"A category on any of twelve marketplaces — prices, ratings and review themes.",
106
107
  marketplace_scan_status:"The products a marketplace scan has collected so far.",
107
- get_marketplace_product:"One product from any marketplace: price, rating, histogram and reviews."
108
+ get_marketplace_product:"One product from any marketplace: price, rating, histogram and reviews.",
109
+ show_marketplace_category_insights:"Your read of a marketplace category, beside its listings."
108
110
  };
109
111
 
110
112
  // ─── The mark ───
@@ -3464,7 +3466,24 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
3464
3466
  // after it — one wrapper instead of a call at each of the many early
3465
3467
  // returns below.
3466
3468
  function render(result){
3467
- try{renderView(result);}finally{initPlayers();setTimeout(reportSize,80);}
3469
+ try{renderView(result);verificationBanner(result);}finally{initPlayers();setTimeout(reportSize,80);}
3470
+ }
3471
+
3472
+ // #107: a show_* view whose rows could not all be matched to what nooticr
3473
+ // returned this session says so above the card. The marketplace card draws
3474
+ // its own. Only the unverified case is drawn: a banner on every honest card
3475
+ // would be noise that trains a reader to skip it.
3476
+ function verificationBanner(result){
3477
+ var d=extractResult(result);
3478
+ if(!d||typeof d!=="object"||d.marketplace)return;
3479
+ var v=d.verification;
3480
+ if(!v||v.status!=="unverified")return;
3481
+ var app=document.getElementById("app");if(!app)return;
3482
+ var b=document.createElement("div");
3483
+ b.setAttribute("data-verification","unverified");
3484
+ b.style.cssText="font-size:12px;padding:8px 10px;margin:0 0 10px;border-radius:8px;border:1px solid var(--border);background:rgba(180,35,24,.06)";
3485
+ b.innerHTML="<b>Not checked against nooticr</b> · "+esc(v.note||"");
3486
+ app.insertBefore(b,app.firstChild);
3468
3487
  }
3469
3488
 
3470
3489
  // ─── Own account ───
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.44",
4
+ "version": "1.26.46",
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": {