@nooticr/mcp 1.26.45 → 1.26.47

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
+ 87 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
 
@@ -173,6 +174,7 @@ a `scanId` for the rest.
173
174
  | `check_nooticr_credits` | free | Balance and billing URL. |
174
175
  | `list_tool_runs` | free | Your tool-call history: what ran, whether it worked, and the credits each call actually took (0 for a free, refunded, cached or replayed call). `scope: "workspace"` shows every member's runs to owners and admins. |
175
176
  | `get_tool_run` | free | One run from that history by id, with its full error. |
177
+ | `get_usage_report` | free | The run ledger added up over a window: total calls, failures and credits actually taken, per tool, per day and the most recent failures. `scope: "workspace"` adds one row per seat, for the workspace's owners and admins. |
176
178
  | `nooticr_login` | free | Re-link the account when a call fails with an authentication error. |
177
179
  | `list_social_connections` | free | What you have connected and what each connection is actually allowed to do — read the account, publish a post, manage comments — as yes, no or **unknown**. "Manage comments" reports the platform's grant; no nooticr tool posts or replies to a comment. Unknown means the grant predates scope recording: treat it as "try it", not as a refusal. Also says which platforms can be linked at all, which is a smaller set than the networks nooticr reads. |
178
180
  | `connect_social_account` | free | A link the user opens to connect one account. They approve at the provider in their own browser; nothing is connected until they do, and no credential passes through the tool or the model's context. Each call mints a fresh link — an old one does not work twice. |
@@ -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
  *
@@ -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
@@ -488,6 +495,7 @@ export const LOADING_PLANS = {
488
495
  check_nooticr_credits: { label: "Checking your balance", kind: "text", n: 1, steps: [], free: true },
489
496
  list_tool_runs: { label: "Reading your run history", kind: "text", n: 1, steps: [], free: true },
490
497
  get_tool_run: { label: "Reading the run", kind: "text", n: 1, steps: [], free: true },
498
+ get_usage_report: { label: "Adding up the run ledger", kind: "text", n: 1, steps: [], free: true },
491
499
  nooticr_getting_started: { label: "Reading where your account stands", kind: "text", n: 1, steps: [], free: true },
492
500
  nooticr_login: { label: "Getting a sign-in link", kind: "text", n: 1, steps: [], free: true },
493
501
  watch_creator: { label: "Adding to your watchlist", kind: "text", n: 1, steps: [], free: true },
@@ -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,29 @@ 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
+ });
314
337
  /**
315
338
  * Whether a show_* view drew nooticr's own figures (#107): the session's
316
339
  * fetch ledger overlays what was actually returned on what the model sent.
@@ -1112,6 +1135,23 @@ export const OUTPUT_SCHEMAS = {
1112
1135
  userId: scalar().describe("Whose run it was; only in the workspace scope."),
1113
1136
  }).nullish(),
1114
1137
  }),
1138
+ get_usage_report: open({
1139
+ report: scalar(),
1140
+ scope: scalar().describe("mine or workspace."),
1141
+ from: scalar(),
1142
+ to: scalar(),
1143
+ totals: open({
1144
+ calls: scalar(),
1145
+ failures: scalar(),
1146
+ credits: scalar().describe("Credits actually taken: free first uses, refunds and replays are zero."),
1147
+ tools: scalar(),
1148
+ seats: scalar(),
1149
+ }).nullish(),
1150
+ byTool: listOf(open({ tool: scalar(), calls: scalar(), failures: scalar(), credits: scalar(), lastUsedAt: scalar() })),
1151
+ bySeat: listOf(open({ userId: scalar(), email: scalar(), name: scalar(), calls: scalar(), failures: scalar(), credits: scalar() })).describe("Workspace reports only; empty on a personal one."),
1152
+ byDay: listOf(open({ day: scalar(), calls: scalar(), failures: scalar(), credits: scalar() })),
1153
+ recentFailures: listOf(open({ id: scalar(), tool: scalar(), error: scalar(), startedAt: scalar(), userId: scalar(), credits: scalar() })),
1154
+ }),
1115
1155
  nooticr_login: open({
1116
1156
  signedIn: scalar().describe("true when the session is already good and no link is needed."),
1117
1157
  loginUrl: scalar().describe("Only present when a sign-in is actually required."),
@@ -1556,26 +1596,7 @@ export const OUTPUT_SCHEMAS = {
1556
1596
  ...amazonScanShape,
1557
1597
  product: amazonProduct.nullish().describe("The single listing, also present as products[0]."),
1558
1598
  }),
1559
- show_amazon_category_insights: open({
1560
- marketplace: scalar(),
1561
- view: scalar(),
1562
- category: scalar(),
1563
- summary: scalar(),
1564
- drivers: listOf(amazonFinding).describe("What makes someone buy, as you read it."),
1565
- barriers: listOf(amazonFinding).describe("What stops them, or makes them return it."),
1566
- strengths: listOf(open({ brand: scalar(), detail: scalar(), asin: scalar() })),
1567
- gaps: listOf(amazonFinding),
1568
- positioning: listOf(open({ angle: scalar(), who: scalar(), why: scalar(), risk: scalar() })),
1569
- products: listOf(amazonProduct).describe("The listings drawn: the scan's own, re-read by scanId, unless verification.status is unverified."),
1570
- rollup: open({}).nullish(),
1571
- scanId: scalar(),
1572
- verification: open({
1573
- status: scalar().describe("verified: the listings are the scan's own. unverified: as re-sent by the model."),
1574
- note: scalar(),
1575
- scanComplete: scalar(),
1576
- notInScan: listOf(z.string()).describe("Listings the model sent that the scan does not contain; not drawn."),
1577
- unknownAsins: listOf(z.string()).describe("ASINs cited in strengths that the scan does not contain."),
1578
- }).nullish(),
1579
- mcpCredits,
1580
- }),
1599
+ show_amazon_category_insights: categoryInsights,
1600
+ // The same card for the other eleven sites (#95); `marketplace` says which.
1601
+ show_marketplace_category_insights: categoryInsights,
1581
1602
  };
@@ -222,6 +222,12 @@ export const TOOL_DEFINITIONS = [
222
222
  description: "One run from list_tool_runs by its id, with its full error text, credentials scrubbed. A run outside your scope reads as not found. No cost to call. Use to look closely at one charge.",
223
223
  inputSchema: z.object({ id: z.number().int(), scope: z.enum(["mine", "workspace"]).optional() }).strict(),
224
224
  },
225
+ {
226
+ name: "get_usage_report",
227
+ title: "Get Usage Report",
228
+ description: "A usage report over a window: total calls, failures and credits actually taken, per tool, per day, and recent failures; scope workspace adds one row per seat, for owners and admins. No cost to call.",
229
+ inputSchema: z.object({ days: z.number().int().min(1).max(366).optional(), from: z.string().optional(), to: z.string().optional(), scope: z.enum(["mine", "workspace"]).optional() }).strict(),
230
+ },
225
231
  {
226
232
  name: "nooticr_login",
227
233
  title: "Nooticr Login",
@@ -510,4 +516,10 @@ export const TOOL_DEFINITIONS = [
510
516
  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
517
  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
518
  },
519
+ {
520
+ name: "show_marketplace_category_insights",
521
+ title: "Show Marketplace Category Insights",
522
+ 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.",
523
+ 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(),
524
+ },
513
525
  ];
@@ -30,7 +30,7 @@ import { registerCollabTools } from "./collab.js";
30
30
  import { registerAmazonTools } from "./amazon.js";
31
31
  import { registerMarketplaceTools } from "./marketplace.js";
32
32
  /** Current MCP server version — bumped on every deploy for traceability. */
33
- export const MCP_SERVER_VERSION = "1.26.45";
33
+ export const MCP_SERVER_VERSION = "1.26.47";
34
34
  /** MCP Apps extension identifier */
35
35
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
36
36
  /** MIME type for MCP Apps HTML resources */
@@ -604,6 +604,7 @@ export function createMcpServer(rawMakeClient, opts) {
604
604
  "check_nooticr_credits",
605
605
  "list_tool_runs",
606
606
  "get_tool_run",
607
+ "get_usage_report",
607
608
  "list_watchlist",
608
609
  "nooticr_getting_started",
609
610
  "understand_social_post",
@@ -694,6 +695,7 @@ export function createMcpServer(rawMakeClient, opts) {
694
695
  "amazon_scan_status",
695
696
  "get_amazon_product",
696
697
  "show_amazon_category_insights",
698
+ "show_marketplace_category_insights",
697
699
  // The marketplace-agnostic trio. Every name here gets a ui://nooticr/<tool>
698
700
  // resource, which is what gives a tool its card — a tool missing from this
699
701
  // list is one the host is told to render and then 404s on.
@@ -2609,6 +2611,43 @@ export function createMcpServer(rawMakeClient, opts) {
2609
2611
  return toolError("get_tool_run failed", err);
2610
2612
  }
2611
2613
  });
2614
+ // The run ledger added up (#93): the question an owner or a security review
2615
+ // asks ("what did the team run this month, what did it cost, what failed")
2616
+ // answered from the same rows as the history, never from memory.
2617
+ server.registerTool("get_usage_report", {
2618
+ title: "Get Usage Report",
2619
+ description: "A usage report over a window: total calls, failures and credits actually taken; one row " +
2620
+ "per tool, most expensive first; one row per day, quiet days included; and the ten most " +
2621
+ "recent failures with their errors, credentials scrubbed. scope: \"workspace\" covers " +
2622
+ "every member of your workspace and adds one row per seat; it is for the workspace's " +
2623
+ "owners and admins. Reads the stored run ledger, so it is free and says what happened, " +
2624
+ "not what is running. No cost to call. Use for \"what did we spend this month and on " +
2625
+ "what\" or an audit of what nooticr did on the workspace's behalf; list_tool_runs is the " +
2626
+ "call-by-call view.",
2627
+ _meta: {
2628
+ ui: { resourceUri: uiResource("get_usage_report") },
2629
+ "ui/resourceUri": uiResource("get_usage_report"),
2630
+ "openai/outputTemplate": appsSdkResource("get_usage_report"),
2631
+ },
2632
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2633
+ outputSchema: OUTPUT_SCHEMAS.get_usage_report,
2634
+ inputSchema: z
2635
+ .object({
2636
+ days: z.number().int().min(1).max(366).optional().describe("The window, in days back from now (default 30)."),
2637
+ from: z.string().optional().describe("Start of the window, ISO date or timestamp. Overrides days."),
2638
+ to: z.string().optional().describe("End of the window, ISO date or timestamp (default now)."),
2639
+ scope: runFilters.scope,
2640
+ })
2641
+ .strict(),
2642
+ }, async (args, extra) => {
2643
+ const client = await makeClient({ ...extra, arguments: args });
2644
+ try {
2645
+ return await toToolResult(await client.callTool("get_usage_report", { ...args }));
2646
+ }
2647
+ catch (err) {
2648
+ return toolError("get_usage_report failed", err);
2649
+ }
2650
+ });
2612
2651
  server.registerTool("nooticr_login", {
2613
2652
  title: "Nooticr Login",
2614
2653
  description: "Get a fresh login URL to re-authenticate your MCP session. Call this tool when you need to reconnect or when the session has expired. No cost to call." +
@@ -45,6 +45,7 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
45
45
  check_nooticr_credits:"Check Credits",
46
46
  list_tool_runs:"Tool Runs",
47
47
  get_tool_run:"Tool Run",
48
+ get_usage_report:"Usage Report",
48
49
  list_watchlist:"Watchlist",
49
50
  detect_spoken_mentions:"Spoken Mentions",
50
51
  nooticr_getting_started:"Getting Started",
@@ -66,7 +67,8 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
66
67
  get_amazon_product:"Get Amazon Product",
67
68
  scan_marketplace_category:"Scan Marketplace Category",
68
69
  marketplace_scan_status:"Marketplace Scan Status",
69
- get_marketplace_product:"Get Marketplace Product"
70
+ get_marketplace_product:"Get Marketplace Product",
71
+ show_marketplace_category_insights:"Marketplace Category Insights"
70
72
  };
71
73
  var TOOL_DESCS={
72
74
  analyze_post:"Frames from the post plus its transcript, for you to read yourself.",
@@ -83,6 +85,7 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
83
85
  check_nooticr_credits:"View your Nooticr credit balance and usage.",
84
86
  list_tool_runs:"Where your credits went: every call, what it cost, and whether it worked.",
85
87
  get_tool_run:"One call from your history, with its full error.",
88
+ get_usage_report:"What was spent, on what, by whom, and what failed.",
86
89
  list_watchlist:"The creators you are watching, and when you last caught up on each.",
87
90
  detect_spoken_mentions:"Brands named out loud in a video, beside what its caption says.",
88
91
  nooticr_getting_started:"Where your account stands and what to try first.",
@@ -102,9 +105,10 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
102
105
  scan_amazon_category:"A category on Amazon — prices, ratings and what reviewers keep raising.",
103
106
  amazon_scan_status:"The listings an Amazon scan has collected so far.",
104
107
  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.",
108
+ scan_marketplace_category:"A category on any of twelve marketplaces — prices, ratings and review themes.",
106
109
  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."
110
+ get_marketplace_product:"One product from any marketplace: price, rating, histogram and reviews.",
111
+ show_marketplace_category_insights:"Your read of a marketplace category, beside its listings."
108
112
  };
109
113
 
110
114
  // ─── The mark ───
@@ -3150,6 +3154,48 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
3150
3154
  return '<div class="fade-in">'+head+body+"</div>";
3151
3155
  }
3152
3156
 
3157
+ // ─── Usage report (get_usage_report) ───
3158
+ //
3159
+ // A spend table: the totals first, then where it went. Credits are what was
3160
+ // actually taken, so a free first use or a refund reads as 0, not as the
3161
+ // tool's price.
3162
+ function renderUsageReport(d){
3163
+ var t=d.totals||{};
3164
+ function tile(label,val){
3165
+ return '<div style="flex:1;min-width:110px;padding:10px 12px;border:1px solid var(--border);border-radius:10px">'
3166
+ +'<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em">'+esc(label)+"</div>"
3167
+ +'<div style="font-size:18px;font-weight:700;margin-top:3px">'+esc(String(val==null?"—":val))+"</div></div>";
3168
+ }
3169
+ function table(title,cols,rows){
3170
+ if(!rows.length)return "";
3171
+ return '<div class="sec-label" style="margin-top:14px">'+esc(title)+"</div>"
3172
+ +'<table style="width:100%;border-collapse:collapse;font-size:13px">'
3173
+ +"<tr>"+cols.map(function(c,i){return '<th style="text-align:'+(i?"right":"left")+';font-weight:600;color:var(--muted);padding:4px 0">'+esc(c)+"</th>";}).join("")+"</tr>"
3174
+ +rows.map(function(r){return '<tr data-usage-row style="border-top:1px solid var(--border)">'+r.map(function(v,i){return '<td style="padding:6px 0;text-align:'+(i?"right":"left")+'">'+esc(String(v))+"</td>";}).join("")+"</tr>";}).join("")
3175
+ +"</table>";
3176
+ }
3177
+ var from=String(d.from||"").slice(0,10),to=String(d.to||"").slice(0,10);
3178
+ var days=(d.byDay||[]),max=1;
3179
+ days.forEach(function(x){max=Math.max(max,Number(x.credits)||0);});
3180
+ var bars=days.length?'<div class="sec-label" style="margin-top:14px">Credits per day</div><div style="display:flex;align-items:flex-end;gap:2px;height:48px">'
3181
+ +days.map(function(x){var c=Number(x.credits)||0;return '<span title="'+esc(String(x.day))+": "+c+' cr" style="flex:1;min-width:2px;background:var(--fg);opacity:'+(c?".6":".12")+';height:'+Math.max(2,Math.round(c/max*46))+'px"></span>';}).join("")+"</div>":"";
3182
+ var fails=(d.recentFailures||[]).map(function(f){
3183
+ return '<div style="padding:6px 0;border-top:1px solid var(--border);font-size:12.5px"><b>'+esc(f.tool||"")+"</b> "
3184
+ +'<span style="color:var(--muted)">'+esc(String(f.startedAt||"").slice(0,16).split("T").join(" "))+"</span>"
3185
+ +(f.error?'<div style="color:var(--muted);word-break:break-word">'+esc(f.error)+"</div>":"")+"</div>";
3186
+ }).join("");
3187
+ return '<div class="fade-in">'
3188
+ +'<div style="font-size:15px;font-weight:700">'+(d.scope==="workspace"?"Workspace usage":"Your usage")+"</div>"
3189
+ +'<div style="font-size:12px;color:var(--muted);margin:2px 0 12px">'+esc(from)+" → "+esc(to)+"</div>"
3190
+ +'<div style="display:flex;gap:8px;flex-wrap:wrap">'+tile("Credits",t.credits)+tile("Calls",t.calls)+tile("Failures",t.failures)
3191
+ +(d.scope==="workspace"?tile("Seats",t.seats):"")+"</div>"
3192
+ +table("By tool",["Tool","Calls","Failed","Credits"],(d.byTool||[]).map(function(r){return [r.tool,r.calls,r.failures,r.credits];}))
3193
+ +table("By seat",["Member","Calls","Failed","Credits"],(d.bySeat||[]).map(function(r){return [r.name||r.email||String(r.userId||"").slice(0,8),r.calls,r.failures,r.credits];}))
3194
+ +bars
3195
+ +(fails?'<div class="sec-label" style="margin-top:14px">Recent failures</div>'+fails:"")
3196
+ +"</div>";
3197
+ }
3198
+
3153
3199
  // ─── Vetting strip ───
3154
3200
  //
3155
3201
  // Only show_collab_shortlist sends these fields, so every other creator card
@@ -4848,6 +4894,7 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
4848
4894
  // Credits
4849
4895
  if(d.gettingStarted===true){app.innerHTML=renderGettingStarted(d);setTimeout(reportSize,50);return;}
4850
4896
  if(d.connector==="google_analytics"||d.connector==="search_console"||d.connector==="posthog"){app.innerHTML=renderConnector(d);setTimeout(reportSize,50);return;}
4897
+ if(d.report===true&&d.totals){app.innerHTML=renderUsageReport(d);setTimeout(reportSize,50);return;}
4851
4898
  if(d.balance!=null||d.tier){
4852
4899
  var bal=Number(d.balance)||0,tier=d.tier||"",ff=d.firstFreeTools||[];
4853
4900
  var freeHtml="";
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.45",
4
+ "version": "1.26.47",
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": {