@nooticr/mcp 1.26.33 → 1.26.35

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.
@@ -55,7 +55,7 @@
55
55
  */
56
56
  import { z } from "zod";
57
57
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
58
- import { platformFromUrl, postSlug } from "./comment-review.js";
58
+ import { classifyGuidance, platformFromUrl, postSlug } from "./comment-review.js";
59
59
  import { clamp, complaintCore, handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
60
60
  import { withEvidence } from "./evidence-digest.js";
61
61
  import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, CREDITS_PER_CREATOR, } from "./spend.js";
@@ -1220,16 +1220,31 @@ export function registerJobTools(server, makeClient, store) {
1220
1220
  existing.foundBy = "both";
1221
1221
  return;
1222
1222
  }
1223
+ // null only when neither spelling arrived; "" when one did and was
1224
+ // blank. `?? null` rather than `?? ""` is the whole distinction.
1225
+ const rawBio = raw.signature ?? raw.bio;
1226
+ const bio = typeof rawBio === "string" ? rawBio : null;
1223
1227
  bySource.set(key, {
1224
1228
  ...raw,
1225
1229
  id: `creator:${platform}:${key}`,
1226
1230
  username,
1227
1231
  followers: numberOf(raw.followers ?? raw.followerCount),
1228
1232
  foundBy: source,
1233
+ // Three states, not two. The server distinguishes "this creator
1234
+ // wrote no bio" (empty string) from "the endpoint did not send one"
1235
+ // (null) — Nooticr/nooticr-server#82, where `signature` came back
1236
+ // empty for 55 of 55 results across two platforms. Collapsing the
1237
+ // null back to "" here would undo that fix at its most important
1238
+ // consumer: `links` is this tool's headline differentiator, and an
1239
+ // empty array reads as "this creator published nothing" whichever
1240
+ // reason produced it.
1241
+ bioRead: bio !== null,
1229
1242
  // What the host should go and read. Pulled out of the bio here
1230
1243
  // rather than left for the model to spot inside prose — see
1231
1244
  // collab.ts for why they are typed and why we do not open them.
1232
- links: extractLinks(String(raw.signature ?? raw.bio ?? ""), typeof raw.externalUrl === "string" ? raw.externalUrl : undefined),
1245
+ links: bio === null
1246
+ ? []
1247
+ : extractLinks(bio, typeof raw.externalUrl === "string" ? raw.externalUrl : undefined),
1233
1248
  });
1234
1249
  };
1235
1250
  try {
@@ -1264,15 +1279,20 @@ export function registerJobTools(server, makeClient, store) {
1264
1279
  const creators = [...bySource.values()].sort((a, b) => (a.foundBy === "both" ? 0 : 1) - (b.foundBy === "both" ? 0 : 1) ||
1265
1280
  numberOf(b.followers) - numberOf(a.followers));
1266
1281
  const withLinks = creators.filter((c) => Array.isArray(c.links) && c.links.length > 0).length;
1282
+ // Candidates whose bio never arrived. Counted rather than inferred from
1283
+ // an empty `links`, because the two are different claims and this tool
1284
+ // is built entirely on the one it cannot make when the bio is missing.
1285
+ const bioUnread = creators.filter((c) => c.bioRead === false).length;
1267
1286
  return evidence([
1268
1287
  collabGuidance({ niche: args.niche, found: creators.length, seed: args.seed, platform }),
1269
1288
  "",
1270
- vettingGuidance(creators.length, withLinks),
1289
+ vettingGuidance(creators.length, withLinks, bioUnread),
1271
1290
  ].join("\n"), {
1272
1291
  mode: "evidence",
1273
1292
  tool: "who_should_i_work_with",
1274
1293
  rubric: COLLAB_RUBRIC,
1275
1294
  withLinks,
1295
+ bioUnread,
1276
1296
  evidenceFrom: args.seed ? ["search_creators", "get_similar_creators"] : ["search_creators"],
1277
1297
  niche: args.niche,
1278
1298
  platform,
@@ -2096,8 +2116,8 @@ export function registerJobTools(server, makeClient, store) {
2096
2116
  "Widens the query into the forms a complaint takes — the plain phrasing, the " +
2097
2117
  "does-anyone-else question, the is-there-a-tool ask — and merges what each returns. " +
2098
2118
  "It is a wide cheap net and NOT a filter: the posts come back for you to judge, and " +
2099
- "many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to " +
2100
- "reddit, where people describe workflow pain in sentences; add twitter for volume.",
2119
+ "many will be off-target. Consumes 2 nooticr credits per search, and each platform is searched once per query shape — three by default, so 6 credits per platform. Defaults to " +
2120
+ "reddit, where people describe workflow pain in sentences; add twitter for volume. Not searchable here: linkedin.",
2101
2121
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
2102
2122
  outputSchema: OUTPUT_SCHEMAS.find_people_with_problem,
2103
2123
  inputSchema: z
@@ -2108,9 +2128,9 @@ export function registerJobTools(server, makeClient, store) {
2108
2128
  "checking what competitors posted\" finds people; \"competitor analysis\" finds " +
2109
2129
  "marketing about competitor analysis."),
2110
2130
  platforms: z
2111
- .array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"]))
2131
+ .array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram"]))
2112
2132
  .optional()
2113
- .describe("Where to look (default reddit). Each one is a separate paid search."),
2133
+ .describe("Where to look (default reddit). Each one is a separate paid search per query shape. Not searchable here: linkedin — no keyword post search exists for it upstream, so asking for it could only ever return nothing, which is why it is absent from this list rather than merely untested."),
2114
2134
  limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."),
2115
2135
  readComments: z
2116
2136
  .boolean()
@@ -2297,6 +2317,19 @@ export function registerJobTools(server, makeClient, store) {
2297
2317
  ? `Note that ${unavailable.length} searches errored — tell the user which networks ` +
2298
2318
  "could not be searched rather than presenting this as the whole picture.\n"
2299
2319
  : "",
2320
+ // A prospect list is a judgement with nowhere to land unless the
2321
+ // labels come back in a shape the view can draw. `bug_report` and
2322
+ // `complaint` are exactly what makes this list actionable — one goes
2323
+ // to engineering, one to positioning — and the chips, counts and
2324
+ // filter that would show them have always been built and never been
2325
+ // asked for on this path (#79).
2326
+ ...(args.readComments && posts.some((p) => Array.isArray(p.commentSample))
2327
+ ? [
2328
+ "",
2329
+ classifyGuidance("The replies you were handed under these posts are unclassified, and labelling " +
2330
+ "them is what turns this list into something sortable.", posts.reduce((n, p) => n + (Array.isArray(p.commentSample) ? p.commentSample.length : 0), 0)),
2331
+ ]
2332
+ : []),
2300
2333
  ownIt,
2301
2334
  ]
2302
2335
  .filter((l, i, all) => !(l === "" && all[i - 1] === ""))
@@ -332,6 +332,58 @@ export const LOADING_PLANS = {
332
332
  // upstream step to wait on — the wait is a database read.
333
333
  mention_trend: { label: "Reading the run history", kind: "text", n: 1, steps: [], free: true },
334
334
  prepare_handoff: { label: "Packaging the handoff", kind: "list", n: 4, steps: [], free: true },
335
+ show_amazon_category_insights: {
336
+ label: "Drawing the category read",
337
+ kind: "list",
338
+ n: 5,
339
+ steps: [],
340
+ free: true,
341
+ },
342
+ // ─── Marketplace ───
343
+ //
344
+ // The wait here is genuinely long and the reason is worth showing rather
345
+ // than hiding behind a spinner: each listing is a browser render behind
346
+ // Amazon's bot defences, and the politeness delay between them is set by
347
+ // what Amazon tolerates rather than by what the machine can do. A plan that
348
+ // said "Working" for four minutes would read as a hang.
349
+ scan_amazon_category: {
350
+ label: "Collecting the category",
351
+ kind: "list",
352
+ n: 6,
353
+ steps: [],
354
+ perUnit: [
355
+ {
356
+ via: "scan_amazon_category",
357
+ label: "Collecting a listing",
358
+ detail: "price, stars, aspects and its reviews",
359
+ credits: 3,
360
+ arg: "limit",
361
+ defaultCount: 10,
362
+ },
363
+ ],
364
+ note: "Amazon is collected politely, so this is minutes rather than seconds. Partial results come back either way.",
365
+ },
366
+ // Free: the poll of a collection already paid for.
367
+ amazon_scan_status: {
368
+ label: "Checking the collection",
369
+ kind: "list",
370
+ n: 6,
371
+ steps: [],
372
+ free: true,
373
+ },
374
+ get_amazon_product: {
375
+ label: "Reading the listing",
376
+ kind: "text",
377
+ n: 1,
378
+ steps: [
379
+ {
380
+ via: "get_amazon_product",
381
+ label: "Fetching the listing",
382
+ detail: "price, stars, aspects and its reviews",
383
+ credits: 3,
384
+ },
385
+ ],
386
+ },
335
387
  // ─── Own-account reads: nooticr's own stored rows ───
336
388
  get_scheduled_posts: { label: "Reading your pipeline", kind: "list", n: 5, steps: [], free: true },
337
389
  get_post_performance: { label: "Reading your numbers", kind: "list", n: 5, steps: [], free: true },
@@ -142,6 +142,16 @@ const post = open({
142
142
  comments: scalar(),
143
143
  shares: scalar(),
144
144
  engagementRate: scalar(),
145
+ // The replies under the post, when a tool opened it — find_people_with_problem's
146
+ // `readComments`. Declared rather than left to `open()` to pass through: a host
147
+ // reading the schema had no way to know these could be here, so the field that
148
+ // costs the most in that call was the one field it could not plan for (#78).
149
+ //
150
+ // `commentsRead: 0` means the thread would not open, NOT that nobody replied.
151
+ // The two read as opposite findings about a prospect, so the distinction is
152
+ // stated here as well as in the digest and the view.
153
+ commentsRead: scalar().describe("How many replies were read under this post. 0 means the thread would not open — not that nobody replied. Absent when nothing tried."),
154
+ commentSample: anyList().describe("The replies themselves, when they were read. Someone answering \"same here, this is exactly my problem\" under another person's thread is a better find than the thread — the permalink to quote is still this post's."),
145
155
  hashtags: listOf(z.string()),
146
156
  // Media comes in threes: the platform URL, an nooticr proxy, and a resolver
147
157
  // that re-fetches when a signed link has expired.
@@ -225,6 +235,65 @@ const analyzed = { ...singlePost, analysis, analyzed: scalar() };
225
235
  * One entry per tool. A tool missing from here declares nothing, which is the
226
236
  * honest option when the shape has not been checked against a real response.
227
237
  */
238
+ /**
239
+ * One Amazon listing, as the scan hands it over.
240
+ *
241
+ * `aspects` and `reviewSummary` are Amazon's own, not ours, and they are the
242
+ * half of a listing that survives the sign-in gate which withholds review
243
+ * bodies — so they are declared beside `reviews` rather than folded into it.
244
+ */
245
+ const amazonProduct = open({
246
+ asin: scalar(),
247
+ url: scalar(),
248
+ title: scalar(),
249
+ brand: scalar(),
250
+ price: scalar(),
251
+ priceValue: scalar(),
252
+ listPrice: scalar(),
253
+ availability: scalar(),
254
+ rating: scalar(),
255
+ ratingCount: scalar(),
256
+ image: scalar(),
257
+ images: listOf(z.string()),
258
+ features: listOf(z.string()),
259
+ categories: listOf(z.string()),
260
+ specs: open({}).nullish(),
261
+ ratingHistogram: open({}).nullish().describe("Star bucket to share, e.g. {\"5\": \"73%\"}."),
262
+ reviewSummary: scalar().describe("Amazon's own digest of the reviews."),
263
+ aspects: listOf(open({ name: scalar(), mentions: scalar(), sentiment: scalar() })),
264
+ reviewsGated: scalar().describe("True when Amazon withheld the review bodies behind sign-in."),
265
+ reviews: anyList(),
266
+ });
267
+ /** One thing you concluded, with what it rests on. */
268
+ const amazonFinding = open({
269
+ label: scalar(),
270
+ detail: scalar(),
271
+ evidence: listOf(z.string()).describe("Review ids or short quotes."),
272
+ strength: scalar().describe("strong / moderate / thin — how well the collected text supports it."),
273
+ });
274
+ const amazonScanShape = {
275
+ guidance: scalar(),
276
+ mode: scalar(),
277
+ marketplace: scalar(),
278
+ scanId: scalar().describe("Pass to amazon_scan_status to pick a running collection back up."),
279
+ status: scalar(),
280
+ complete: scalar().describe("False means more listings are still coming."),
281
+ query: scalar(),
282
+ domain: scalar(),
283
+ progress: open({
284
+ done: scalar(),
285
+ total: scalar(),
286
+ message: scalar(),
287
+ elapsedMs: scalar(),
288
+ fromCache: scalar(),
289
+ }).nullish(),
290
+ rollup: open({}).nullish().describe("The arithmetic over the set: price and rating spread, aspects by brand, 1-2 star share."),
291
+ products: listOf(amazonProduct),
292
+ reviews: anyList().describe("Every review across the scan, worst-rated first — the barriers are the half a summary drops."),
293
+ errors: anyList(),
294
+ mcpCredits,
295
+ };
296
+ const amazonScan = open(amazonScanShape);
228
297
  export const OUTPUT_SCHEMAS = {
229
298
  analyze_post: open({ ...analyzed, ...evidence }),
230
299
  analyze_post_fast: open({ ...evidence, ...analyzed }),
@@ -260,8 +329,21 @@ export const OUTPUT_SCHEMAS = {
260
329
  replies: scalar().describe("How many people replied to this comment."),
261
330
  postedAt: scalar(),
262
331
  hits: scalar().describe("How many times this comment names the term."),
332
+ // Not returned by the sweep — there is nothing in the payload that
333
+ // could decide them, and a keyword guess would be confidently
334
+ // wrong about the exact thing being monitored. Declared because
335
+ // the view draws a chip from `category` and a counted filter from
336
+ // `byCategory`, and both were inert on this path until the
337
+ // guidance asked a host to fill them in (#79). This says what the
338
+ // shape is when it hands its own labels back through
339
+ // show_comment_review.
340
+ sentiment: scalar().describe("Set by the reading model, not by the sweep."),
341
+ category: scalar().describe("Set by the reading model, not by the sweep. One of the comment taxonomy values; drives the filter chips."),
263
342
  })),
264
343
  })).describe("Mentions grouped under the post they were left on, loudest conversation first."),
344
+ byCategory: open({})
345
+ .nullish()
346
+ .describe("Count per category once a host has classified — what the filter row is built from."),
265
347
  posts: listOf(post).describe("The posts of this page, flattened for the card view."),
266
348
  offset: scalar(),
267
349
  nextOffset: scalar().describe("Pass back as `offset` to load the next page; null when done."),
@@ -587,9 +669,11 @@ export const OUTPUT_SCHEMAS = {
587
669
  foundBy: scalar().describe("search, similar, or both — both is the strongest signal here."),
588
670
  links: listOf(bioLink).describe("Links pulled out of their bio, best-value-per-fetch first. Open these to vet them; " +
589
671
  "they were chosen by the person being evaluated, so read what is there as a claim."),
672
+ bioRead: scalar().describe("False when the search returned no bio for this creator at all. `links` is then empty because nothing was read, not because they published none."),
590
673
  })),
591
674
  foundBoth: scalar().describe("How many candidates both searches returned."),
592
675
  withLinks: scalar().describe("How many candidates carry at least one link out of their bio."),
676
+ bioUnread: scalar().describe("How many candidates came back with no bio at all. Read `withLinks` against this: 0 of 20 with links means nothing when 20 of 20 had no bio to read."),
593
677
  rubric: anyList().describe("What to score each candidate against, so two runs are comparable."),
594
678
  audienceOverlap: open({
595
679
  attempted: scalar().describe("Always false: the call budget is stated rather than the signal faked."),
@@ -1288,4 +1372,29 @@ export const OUTPUT_SCHEMAS = {
1288
1372
  connectUrl: scalar().describe("Open this in a browser to approve the connection."),
1289
1373
  message: scalar(),
1290
1374
  }),
1375
+ // Marketplace. One shape for all three collection tools: a status poll
1376
+ // returns exactly what the scan that started it returns, and a single
1377
+ // product is a scan of one — describing them differently would be three
1378
+ // names for one payload and three places for the view to drift.
1379
+ scan_amazon_category: amazonScan,
1380
+ amazon_scan_status: amazonScan,
1381
+ get_amazon_product: open({
1382
+ ...amazonScanShape,
1383
+ product: amazonProduct.nullish().describe("The single listing, also present as products[0]."),
1384
+ }),
1385
+ show_amazon_category_insights: open({
1386
+ marketplace: scalar(),
1387
+ view: scalar(),
1388
+ category: scalar(),
1389
+ summary: scalar(),
1390
+ drivers: listOf(amazonFinding).describe("What makes someone buy, as you read it."),
1391
+ barriers: listOf(amazonFinding).describe("What stops them, or makes them return it."),
1392
+ strengths: listOf(open({ brand: scalar(), detail: scalar(), asin: scalar() })),
1393
+ gaps: listOf(amazonFinding),
1394
+ positioning: listOf(open({ angle: scalar(), who: scalar(), why: scalar(), risk: scalar() })),
1395
+ products: listOf(amazonProduct).describe("The listings, passed through so the view can draw them."),
1396
+ rollup: open({}).nullish(),
1397
+ scanId: scalar(),
1398
+ mcpCredits,
1399
+ }),
1291
1400
  };
@@ -393,8 +393,8 @@ export const TOOL_DEFINITIONS = [
393
393
  {
394
394
  name: "find_people_with_problem",
395
395
  title: "Find People With A Problem",
396
- description: "Find people describing a problem in their own words — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it (\"I spend hours checking competitors by hand\") and this searches for posts shaped like that complaint, not for the topic it is about. That difference is the point: searching the TOPIC finds marketing about it, which is what discover_social_posts is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and merges what each returns. It is a wide cheap net and NOT a filter: the posts come back for you to judge, and many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to reddit, where people describe workflow pain in sentences; add twitter for volume.",
397
- inputSchema: z.object({ problem: z.string().describe("The problem in a person's own words, not a topic. \"I waste an hour a day checking what competitors posted\" finds people; \"competitor analysis\" finds marketing about competitor analysis."), platforms: z.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"])).optional().describe("Where to look (default reddit). Each one is a separate paid search."), limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."), readComments: z.boolean().optional().describe("Open the most promising results and read their comments too (default false). On Reddit especially, the person with the problem is at least as often replying under someone else's thread as posting their own, and a post-only search cannot see any of them. Costs 2 nooticr credits per post opened, up to 5 \u2014 and they are opened on the MERGED, deduped results, so it is five extra calls in total rather than five per query shape per platform."), queries: z.array(z.string()).optional().describe("Search these exact phrasings instead of the ones derived from `problem` (max 4). You are better at this than a string template is: write how a person would actually word the complaint on the network being searched \u2014 a Reddit title, an X post \u2014 and keep each one short, because this is a keyword search and a long sentence matches on its commonest words.") }).strict(),
396
+ description: "Find people describing a problem in their own words — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it (\"I spend hours checking competitors by hand\") and this searches for posts shaped like that complaint, not for the topic it is about. That difference is the point: searching the TOPIC finds marketing about it, which is what discover_social_posts is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and merges what each returns. It is a wide cheap net and NOT a filter: the posts come back for you to judge, and many will be off-target. Consumes 2 nooticr credits per search, and each platform is searched once per query shape — three by default, so 6 credits per platform. Defaults to reddit, where people describe workflow pain in sentences; add twitter for volume. Not searchable here: linkedin.",
397
+ inputSchema: z.object({ problem: z.string().describe("The problem in a person's own words, not a topic. \"I waste an hour a day checking what competitors posted\" finds people; \"competitor analysis\" finds marketing about competitor analysis."), platforms: z.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram"])).optional().describe("Where to look (default reddit). Each one is a separate paid search per query shape. Not searchable here: linkedin — no keyword post search exists for it upstream, so asking for it could only ever return nothing, which is why it is absent from this list rather than merely untested."), limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."), readComments: z.boolean().optional().describe("Open the most promising results and read their comments too (default false). On Reddit especially, the person with the problem is at least as often replying under someone else's thread as posting their own, and a post-only search cannot see any of them. Costs 2 nooticr credits per post opened, up to 5 \u2014 and they are opened on the MERGED, deduped results, so it is five extra calls in total rather than five per query shape per platform."), queries: z.array(z.string()).optional().describe("Search these exact phrasings instead of the ones derived from `problem` (max 4). You are better at this than a string template is: write how a person would actually word the complaint on the network being searched \u2014 a Reddit title, an X post \u2014 and keep each one short, because this is a keyword search and a long sentence matches on its commonest words.") }).strict(),
398
398
  },
399
399
  {
400
400
  name: "search_spoken_mentions",
@@ -414,4 +414,28 @@ export const TOOL_DEFINITIONS = [
414
414
  description: "Patch your own product's fields \u2014 omitted arguments leave their column unchanged, so this cannot blank a field by not mentioning it. Takes appId, optional when your workspace has exactly one product; every other field is snake_case, the same names create_product takes and read the same way. The result lists which fields were actually written, so a name you spelled wrong shows up as a field that did not change rather than as a silent no-op. Free \u2014 no AI call, just a row.",
415
415
  inputSchema: z.object({ appId: z.number().int().optional().describe("Your product's id. Omit only with a single-product workspace."), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), website_url: z.string().optional(), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
416
416
  },
417
+ {
418
+ name: "scan_amazon_category",
419
+ title: "Scan Amazon Category",
420
+ description: "Collect an Amazon category and read it: listings with prices, star histograms, Amazon's own review-aspect counts and the review text itself, plus the arithmetic over them (price spread, rating spread, which aspects recur across how many brands, and the share of each listing's ratings sitting at 1-2 stars). Pass query to search the category, asins to pin named competitors, or both \u2014 named competitors keep their place at the front. You write the read: purchase drivers, barriers, what the incumbents do well, the gaps, and how a new entrant could position; show_amazon_category_insights draws it for free afterwards. Collection runs in the background, so this returns everything ready within waitSeconds plus a scanId to continue with amazon_scan_status. Consumes 3 nooticr credits per listing collected \u2014 a listing with reviews is a real browser render behind Amazon's bot defences.",
421
+ inputSchema: z.object({ query: z.string().optional().describe("Category or keyword to search on Amazon, e.g. 'ashwagandha gummies'."), asins: z.array(z.string()).optional().describe("Named competitors: ASINs or any Amazon product URL. Kept at the front of the set."), limit: z.number().int().optional().describe("Listings to collect (default 10, max 50)."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), domain: z.string().optional().describe("Marketplace host, e.g. www.amazon.co.uk (default www.amazon.com)."), waitSeconds: z.number().int().optional().describe("How long to wait before returning partial results (default 90, max 240)."), focus: z.string().optional().describe("An extra question to answer from the reviews.") }).strict(),
422
+ },
423
+ {
424
+ name: "amazon_scan_status",
425
+ title: "Amazon Scan Status",
426
+ description: "Pick up a scan_amazon_category collection that was still running, by scanId \u2014 the listings and reviews collected since, with the same rollup and the same instructions for reading them. Free: it is the poll scan_amazon_category asked for, and charging for the second half of one answer would bill a wait this server chose. Call it when a scan came back incomplete, then write your read from the full set.",
427
+ inputSchema: z.object({ scanId: z.string().describe("The scanId scan_amazon_category returned."), waitSeconds: z.number().int().optional().describe("How long to wait for more listings before answering (default 30, max 240)."), focus: z.string().optional().describe("Same optional steer as scan_amazon_category.") }).strict(),
428
+ },
429
+ {
430
+ name: "get_amazon_product",
431
+ title: "Get Amazon Product",
432
+ description: "Fetch one Amazon listing by ASIN or URL: price, rating, the full star histogram, features, specs, Amazon's review digest and aspect breakdown, and the review text. Use for a single product; for a category or a competitive set, scan_amazon_category collects many in one job and rolls them up. Consumes 3 nooticr credits.",
433
+ inputSchema: z.object({ asin: z.string().optional().describe("ASIN, or a full Amazon product URL."), url: z.string().optional().describe("Full Amazon product URL, if you have that instead."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), domain: z.string().optional().describe("Marketplace host (default www.amazon.com).") }).strict(),
434
+ },
435
+ {
436
+ name: "show_amazon_category_insights",
437
+ title: "Show Amazon Category Insights",
438
+ 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. Free, and makes no requests: it only draws what you pass it, 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.",
439
+ 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(),
440
+ },
417
441
  ];
@@ -13,7 +13,7 @@ import { registerPrompts } from "./prompts.js";
13
13
  import { OUTPUT_SCHEMAS, anyObject } from "./output-schemas.js";
14
14
  import { deriveHashtags, derivedNote } from "./hashtags.js";
15
15
  import { createTaskStore, registerSlowTool } from "./tasks.js";
16
- import { COMMENT_CATEGORIES, COMMENT_SENTIMENTS, platformFromUrl, reviewGuidance, toEvidence, } from "./comment-review.js";
16
+ import { COMMENT_CATEGORIES, COMMENT_SENTIMENTS, classifyGuidance, platformFromUrl, reviewGuidance, toEvidence, } from "./comment-review.js";
17
17
  import { costSentence, EVIDENCE_PLANS, fetchBillingNote, frameIndex, framesToBlocks, planCalls, scoreDraftGuidance, KNOWN_PLATFORMS, platformFailureGuidance, } from "./evidence.js";
18
18
  import { evidenceDigest } from "./evidence-digest.js";
19
19
  import { confirmSpend, declinedResult, searchMentionsCost, SEARCH_PLATFORMS, DISCOVERABLE_PLATFORMS, } from "./spend.js";
@@ -25,8 +25,9 @@ import { registerConnectionTools } from "./connections.js";
25
25
  import { loadingPlansJson } from "./loading-plans.js";
26
26
  import { registerHandoff } from "./handoff.js";
27
27
  import { registerCollabTools } from "./collab.js";
28
+ import { registerAmazonTools } from "./amazon.js";
28
29
  /** Current MCP server version — bumped on every deploy for traceability. */
29
- export const MCP_SERVER_VERSION = "1.26.33";
30
+ export const MCP_SERVER_VERSION = "1.26.35";
30
31
  /** MCP Apps extension identifier */
31
32
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
32
33
  /** MIME type for MCP Apps HTML resources */
@@ -278,7 +279,17 @@ function withSource(result, source, platform) {
278
279
  return result;
279
280
  return { ...result, structuredContent: { ...result.structuredContent, source, platform } };
280
281
  }
281
- async function toToolResult(proxy) {
282
+ /**
283
+ * `guidance`, when given, is steering this repo adds to a backend passthrough
284
+ * — and it goes into BOTH channels deliberately.
285
+ *
286
+ * Claude Code drops every text block when a result also carries
287
+ * structuredContent, and a host rendering the UI view does the reverse: it
288
+ * hands the model the text and gives structuredContent to the widget. So a
289
+ * guidance string in one place only reaches roughly half the hosts, which is
290
+ * the failure #44/#51 were and the reason chain:map gates on it.
291
+ */
292
+ async function toToolResult(proxy, guidance) {
282
293
  // MCP Apps views (ChatGPT & Claude) render the interactive HTML card, which
283
294
  // already embeds all thumbnails/videos. Claude's Apps bridge rejects a tool
284
295
  // result that mixes raw `image` blocks with an app view ("could not be
@@ -304,10 +315,11 @@ async function toToolResult(proxy) {
304
315
  const textJson = JSON.stringify(forHosts, null, 2);
305
316
  // Replace image URLs in HTML with proxied versions
306
317
  const proxiedHtml = htmlPrefix ? proxyImageUrlsInHtml(htmlPrefix) : "";
307
- const text = proxiedHtml ? `${proxiedHtml}\n\n${textJson}` : textJson;
318
+ const body = proxiedHtml ? `${proxiedHtml}\n\n${textJson}` : textJson;
319
+ const text = guidance ? `${guidance}\n\n${body}` : body;
308
320
  return {
309
321
  content: [{ type: "text", text }],
310
- structuredContent: forHosts,
322
+ structuredContent: guidance ? { ...forHosts, guidance } : forHosts,
311
323
  };
312
324
  }
313
325
  /** Replace external image/video src, poster and href URLs in HTML with proxied versions. */
@@ -627,6 +639,12 @@ export function createMcpServer(rawMakeClient, opts) {
627
639
  // grant each connection carries is a table, which is what a view is for.
628
640
  "list_social_connections",
629
641
  "connect_social_account",
642
+ // Marketplace. All four draw: the three collection tools render the
643
+ // listings and their reviews, and the view draws the read on top of them.
644
+ "scan_amazon_category",
645
+ "amazon_scan_status",
646
+ "get_amazon_product",
647
+ "show_amazon_category_insights",
630
648
  ];
631
649
  // Human-readable resource name per tool (used in resources/list + tools/list).
632
650
  function resourceName(tool) {
@@ -706,6 +724,12 @@ export function createMcpServer(rawMakeClient, opts) {
706
724
  // LinkedIn
707
725
  "https://*.licdn.com",
708
726
  "https://*.linkedin.com",
727
+ // Amazon product images. Listings serve from m.media-amazon.com and, on
728
+ // older listings, images-na.ssl-images-amazon.com — a listing whose picture
729
+ // is blocked draws as a brand initial, which is a worse comparison screen
730
+ // than the one the scan paid for.
731
+ "https://*.media-amazon.com",
732
+ "https://*.ssl-images-amazon.com",
709
733
  ];
710
734
  const apiUrl = process.env.NOOTICR_API_URL || process.env.NOOTICR_BASE_URL;
711
735
  if (apiUrl && apiUrl.trim()) {
@@ -2172,7 +2196,15 @@ export function createMcpServer(rawMakeClient, opts) {
2172
2196
  return declinedResult(credits, "That sweep");
2173
2197
  const client = await makeClient({ ...extra, arguments: args });
2174
2198
  try {
2175
- return await toToolResult(await client.callTool("search_mentions", { ...args }));
2199
+ const proxy = await client.callTool("search_mentions", { ...args });
2200
+ // Ask for the labels the view is already built to draw. Everything on the
2201
+ // receiving end has existed for as long as the chips have — the per-row
2202
+ // chip, the counted filter, the click-to-filter — and this sweep is the
2203
+ // most expensive call in the product, so it drew all three empty every
2204
+ // time for want of one instruction (#79).
2205
+ const structured = (proxy.structured ?? {});
2206
+ const groups = Array.isArray(structured.threads) ? structured.threads.length : 0;
2207
+ return await toToolResult(proxy, classifyGuidance(`They are comments naming "${args.term}", grouped under the ${groups} posts they were left on.`, groups));
2176
2208
  }
2177
2209
  catch (err) {
2178
2210
  return toolError("search_mentions failed", err);
@@ -2351,5 +2383,9 @@ export function createMcpServer(rawMakeClient, opts) {
2351
2383
  // classified for a tracker on another server, the other draws what it scored.
2352
2384
  registerHandoff(server);
2353
2385
  registerCollabTools(server);
2386
+ // Marketplace intelligence (amazon.ts): the category collection, the poll
2387
+ // that picks it back up, a single listing, and the free view for the read
2388
+ // the model writes out of them.
2389
+ registerAmazonTools(server, makeClient);
2354
2390
  return server;
2355
2391
  }