@nooticr/mcp 1.26.41 → 1.26.43

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,10 +52,10 @@ npx @nooticr/mcp login # one-time sign-in (Google)
52
52
 
53
53
  ## Tools
54
54
 
55
- 72 tools, grouped by what you are trying to do. Prices are in nooticr credits and
55
+ 85 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
- Seven of them — the ones under **Answer a question you actually have** — are not
58
+ The job tools under **Answer a question you actually have** are not
59
59
  endpoint wrappers. Each names a job, fans out over the calls that job needs,
60
60
  groups the evidence by whatever you are deciding about, gives every item an id
61
61
  a follow-up tool can act on, and hands the reading to your model rather than to
@@ -67,7 +67,7 @@ them caps that fan-out with an argument.
67
67
  | Tool | Credits | What it is for |
68
68
  |------|---------|----------------|
69
69
  | `get_social_media` | 1 | The post's facts and media — contentType, title, caption, author, stats, direct media URLs, plus an inline thumbnail. Use when you want the post itself and nothing interpreted. |
70
- | `get_post_transcript` | 1 | The words actually spoken. Where the platform publishes a caption track (TikTok, Douyin, YouTube) it is read as-is — exact rather than inferred, and far cheaper than watching the video; everywhere else the post's own audio is transcribed, which needs speech-to-text configured on the server. Use before any analysis when the wording matters. |
70
+ | `get_post_transcript` | 1 | The words actually spoken. Where the platform publishes a caption track (TikTok, Douyin, YouTube) it is read as-is — exact rather than inferred, and far cheaper than watching the video; everywhere else the post's own audio is transcribed, which needs speech-to-text configured on the server. `format: "srt"` or `"vtt"` also returns a ready caption file built from the track's own timing, at no extra cost. Use before any analysis when the wording matters. |
71
71
  | `get_post_frames` | 2 | Frames sampled evenly across a post's video, returned as **images you can actually look at** — not a description of them. ffmpeg opens the stream directly rather than downloading it, so HLS works and an expired link is re-resolved on the spot. Verified live at 3/3 on TikTok, YouTube, Instagram, Douyin and X; Reddit works on video posts. A carousel or slideshow returns its own images unchanged. Each frame costs roughly 1,200 tokens of your context. |
72
72
  | `get_post_comments` | 2 | Top comments plus the themes the platform clusters them into, with which ones the creator pinned or liked. Use when you want to read what people wrote. |
73
73
 
@@ -78,7 +78,7 @@ them caps that fan-out with an argument.
78
78
  | `analyze_post_fast` | 2 | The post's transcript, caption and stats — everything but the pictures, which is what makes it the cheap read. Two fetches: `get_social_media` (1) and `get_post_transcript` (1). The sensible default. |
79
79
  | `analyze_post` | 3 | Frames sampled across the video, as **images your model can actually look at**, plus the transcript. Two fetches: `get_post_frames` (2) and `get_post_transcript` (1). Use when the visuals are the point — framing, editing, on-screen text. |
80
80
  | `understand_social_post` | 3 | The same two fetches, asked for a description of what physically happens on screen rather than why it works. Use when you need the events, not the strategy. |
81
- | `analyze_comments` | 2 | The comment section, every comment with a stable id, and the taxonomy to label them with — sentiment, and whether each is praise, a complaint, a bug report, a question, a request, a comparison or spam. The same `get_post_comments` call, at the same price as reading them directly. |
81
+ | `analyze_comments` | 2 | The comment section, every comment with a stable id, and the taxonomy to label them with — sentiment, and whether each is praise, a complaint, a bug report, a question, a request, a comparison or spam. The same `get_post_comments` call, at the same price as reading them directly. Pass `verbatim: true` to have every item rendered whole for quoting rather than clipped to fit; same price. |
82
82
  | `show_comment_review` | free | Draws the classifications your model produced — every comment with its sentiment and category, filterable and selectable. Makes no requests; it only renders what you pass it. |
83
83
  | `show_post_analysis` | free | Draws the analysis your model wrote from `analyze_post`/`analyze_post_fast`/`understand_social_post` — hook strength, script structure, quotable lines, suggested hashtags and more, whichever fields you produced. Makes no requests; it only renders what you pass it. |
84
84
  | `compare_posts` | 1 | The first of two to five posts, fetched with its stats, and the comparison left to you. Fetch the rest with `get_social_media` at 1 credit each. Use when performance differs and you need to know why. |
@@ -92,12 +92,14 @@ them caps that fan-out with an argument.
92
92
  | `get_user_posts` | 2 | One creator's recent posts with stats. Use to scan an account. |
93
93
  | `search_creators` | 2 | People by what they make — designers, developers, photographers, anyone with an audience — found by craft, niche or keyword. Use when you know the kind of person but not their names. Searches TikTok, Instagram and Xiaohongshu; **not** YouTube, Douyin, X, Reddit or LinkedIn, which cannot be creator-searched upstream. |
94
94
  | `get_similar_creators` | 2 | Lookalikes for a creator that already works. |
95
+ | `suggest_creator_identity` | 2 per network searched, plus 2 to find the handle (6 by default) | The same person on the other networks, from one handle you already know — with the evidence for each: a link published in both bios, one bio naming the other account, an identical handle, a matching display name. **Suggests, never merges**: nothing is stored, a human confirms, and two accounts' follower counts are never added together. TikTok, Instagram and Xiaohongshu. |
95
96
  | `discover_sounds` | 2 | Trending audio with playable previews. Sound is a major ranking signal on TikTok. |
96
97
  | `discover_hashtags` | 2 | What to tag with. TikTok comes from the Creative Center trend board — volumes plus rising/cooling/steady. The other eight searchable networks have no trend board upstream, so tags are counted across a live sweep of a niche you name: real and current, but a single sample, so no rising/cooling signal. `source` says which you got. LinkedIn cannot be swept. |
97
98
  | `find_hook_pattern` | 2 | A creator's recent posts, so their opening lines can be read as a set and turned into fill-in-the-blank templates. One `get_user_posts` call. |
98
99
  | `search_mentions` | 2 per network (5 for Xiaohongshu) | **Brand monitoring.** Every *comment* that names a term, across nine networks at once, grouped under the post it was left on. A brand is named far more often in the replies than in a caption, so the comment is the unit — not the post. Takes a `since` date to read a past window, and pages with `offset`/`pageSize` so a nine-network sweep does not arrive all at once. Does not read speech inside a video — `search_spoken_mentions` does, on TikTok, YouTube and Douyin. |
99
100
  | `watch_creator` | free | Add a creator to your watchlist. Stores the handle only — nothing is fetched. |
100
101
  | `unwatch_creator` | free | Drop a creator from the watchlist. |
102
+ | `list_watchlist` | free | Who you are watching: handle, platform, note, and when you last caught up on each. |
101
103
  | `catch_up_watchlist` | 2 per creator | What everyone you watch has posted since your last catch-up. Compares against the snapshot taken last time and moves it forward, so it answers "what is new" rather than "what exists". |
102
104
  | `create_brand_watch` | free to call | Schedule a recurring sweep and get emailed only what is new. `kind: "mentions"` (default) is a recurring `search_mentions` sweep for `term`/`platforms`; `kind: "competitor"` is a recurring `get_user_posts` check on one creator (`handle`/`platform`), mailing only what beats their own recent median. Two calls by design: the first returns the quote (cost per run, cadence, cost per day) and a `confirmationToken`; nothing is created or charged until you call it again with `confirm: true` and that token. Each run then bills the same as calling the matching tool yourself — 2 credits per network (5 for Xiaohongshu) for mentions, a flat 2 credits for competitor. |
103
105
  | `list_brand_watches` | free | Every watch you have scheduled: term, networks, cadence, cost per run, credits spent so far, runs made, and when the next one is due. |
@@ -110,7 +112,7 @@ them caps that fan-out with an argument.
110
112
 
111
113
  | Tool | Credits | What it is for |
112
114
  |------|---------|----------------|
113
- | `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply, because no nooticr connection carries comment-write permission on any network. `limit` caps how many posts are opened, which is the price. |
115
+ | `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply: nooticr does not post comments on any network, even where a connection's grant allows managing them. `limit` caps how many posts are opened, which is the price. |
114
116
  | `prepare_handoff` | free | **The hand-off to your tracker.** Turns items you classified — a bug report in a comment, a complaint said out loud in a video — into the exact `title`, `body` and `labels` to file through whichever GitHub, Jira or Linear MCP server the host also has connected. nooticr files nothing and holds no tracker credential. The body carries the quote fenced and framed as a third-party report rather than as instructions, because the issue is read next by a coding agent; contact details are redacted, `@handles` and `#numbers` are defanged so filing notifies nobody by accident, and a `searchFirst` string dedupes a second sweep against the first. Makes no requests. |
115
117
  | `show_audience_replies` | free | Lays your drafts out for a person to work through, grouped under the post, each with what you decided to do about it. Sends nothing; fetches nothing. |
116
118
  | `show_standings` | free | Draws the standings your model read out of `compare_creators` or `watchlist_standings` — one row per creator, with the window and the median it was scored against. `ranking` records which axis you ordered on, and `tooThin` draws a creator with too short a window as unranked rather than last: missing from a comparison is not the same as losing it. Makes no requests. |
@@ -119,10 +121,11 @@ them caps that fan-out with an argument.
119
121
  | `compare_creators` | 2 per creator (4-10) | Two to five creators side by side on the one axis that compares: each post's ratio to **that creator's own** median. Per creator you get the window that was scored, their median, the share of it that beat that median, how hard they beat it when they did, every ratio, and their best and worst post. It does not rank them — "how often they land one" and "how big it is when they do" usually disagree, and a hit rate over a short window is one post either way, so the window travels with every number and the read is yours. |
120
122
  | `watchlist_standings` | 2 per watched creator | The same, across everyone on your watchlist: "who moved" in one call instead of one per creator. Reading the watchlist is free; the fetches are not, so it confirms the total before spending. `catch_up_watchlist` answers what is **new**; this answers how it **did**. |
121
123
  | `who_should_i_work_with` | 2, or 4 with a seed | A collaboration shortlist: a keyword search merged with the lookalikes of a creator who already fits, marked by which search found each one. Every candidate also carries the **links pulled out of their bio**, typed and sorted by how much opening one will tell you — a repository to read the code in, their own site, a link hub that holds the real links — so vetting is reading the work rather than re-reading the follower count. Those links are never fetched here: they came out of a field the person being evaluated controls, so the host opens them, and the result says so. It does **not** measure audience overlap — that costs about nine credits a candidate, so the result says so and shows how to check a finalist rather than faking the signal. |
122
- | `find_people_with_problem` | 6 per platform | 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 and it searches for posts shaped like that complaint rather than for the topic it is about: 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 marks which one surfaced each post. A wide cheap net and **not** a filter: the posts come back for you to judge, and plenty will be off-target. Defaults to reddit, where people describe workflow pain in sentences. Each platform is searched once per query shape — three by default, at 2 credits a search. Not searchable here: linkedin, which has no keyword post search upstream. |
124
+ | `find_people_with_problem` | 6 per platform | 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 and it searches for posts shaped like that complaint rather than for the topic it is about: 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 marks which one surfaced each post. A wide cheap net and **not** a filter: the posts come back for you to judge, and plenty will be off-target. Defaults to reddit, where people describe workflow pain in sentences. Each platform is searched once per query shape — three by default, at 2 credits a search. Not searchable here: linkedin, which has no keyword post search upstream. Pass `verbatim: true` to have every item rendered whole for quoting rather than clipped to fit; same price. |
123
125
  | `show_collab_shortlist` | free | Draws the candidates you scored, ranked, and asks the user which to approach. The scores are attributed to your model, not presented as a nooticr rating, and a candidate scored without anything having been opened is marked unverified. Makes no requests. |
124
126
  | `why_did_this_underperform` | 3 | One post against the creator's own recent distribution, with the post taken back out of its own baseline. Returns median, quartiles, ratio and percentile, so the answer can be "this is an ordinary result, not a failure". Different question from `compare_posts`, which weighs two URLs you already picked. |
125
127
  | `what_should_i_make_next` | 2 + 2 per post read + 2 (12 by default) | Demand against supply: what your commenters explicitly ask for, set beside what a niche sweep shows is already being made. A gap nobody asked for is noise; a request nobody serves is the opportunity. Falls back to your most-used hashtag when you name no niche. |
128
+ | `detect_spoken_mentions` | 2 | **Every brand named out loud in one video.** The spoken transcript and the written caption, side by side, for your model to list each brand with its verbatim sentence, the creator's framing and whether it was said, written or both. Works where the creator never enabled captions, because the audio is transcribed. 1 credit for `get_post_transcript` plus 1 for `get_social_media`; nothing when no transcript can be made. |
126
129
  | `search_spoken_mentions` | 2 per platform narrowed by niche + 2 per creator handle + 1 per transcript, up to `maxTranscripts` | **The mirror of `search_mentions`, for what was said rather than typed.** Narrows to candidate posts (a niche sweep, named handles, and/or your watchlist), transcribes only the most-viewed survivors up to a hard ceiling, and searches the words for the term — TikTok, YouTube and Douyin, and only where the creator actually enabled captions. Reports how many candidates were found, transcribed and matched, so the spend is legible. |
127
130
 
128
131
  ### Study a market on Amazon
@@ -148,7 +151,7 @@ a `scanId` for the rest.
148
151
  | `scan_marketplace_category` | 3 per product collected (30 by default) | The same category read on eleven more sites — AliExpress, Cdiscount, Flipkart, Mercado Libre, Lazada, Otto, Rakuten, Trendyol, Jumia, Vinted and Temu. Vinted is resale rather than retail, so its `rating` describes the seller and the scan says so in `ratingScope` — do not read it as a product's reviews. `marketplace` picks the site; `market` picks the storefront for the sites that have several, and the argument names the real set per site so a model cannot send Otto a Lazada region. Same rollup, same confirmation before spending. |
149
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. |
150
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. |
151
- | `show_amazon_category_insights` | free | Draws the read **your model** wrote — purchase drivers, barriers, what each brand does well, the gaps, the positioning angles — beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. The conclusions are attributed to your model, never presented as a nooticr rating of anyone's product. Makes no requests. |
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. |
152
155
 
153
156
  ### Make something
154
157
 
@@ -166,9 +169,12 @@ a `scanId` for the rest.
166
169
 
167
170
  | Tool | Credits | What it is for |
168
171
  |------|---------|----------------|
172
+ | `nooticr_getting_started` | free | **Start here.** Where this account stands — balance, connected accounts, watchlist — and the next calls worth making, each with its price and an example. The server's `instructions` point every host at it. |
169
173
  | `check_nooticr_credits` | free | Balance and billing URL. |
174
+ | `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
+ | `get_tool_run` | free | One run from that history by id, with its full error. |
170
176
  | `nooticr_login` | free | Re-link the account when a call fails with an authentication error. |
171
- | `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**. 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. |
177
+ | `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. |
172
178
  | `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. |
173
179
  | `create_product` | free | Create a product in your own workspace — the row every other own-account tool needs before it has anything to work with. Always creates in the calling session's workspace, never one you could name. `name` and `slug` are required; every other field is snake_case and read by exact key, so a camelCase `websiteUrl` lands nowhere. Writes no brand playbook. |
174
180
  | `update_product` | free | Patch a product's fields. An omitted argument leaves its column unchanged, so this cannot blank a field by not mentioning it, and the result lists what was actually written — a misspelled field shows up as one that did not change rather than as a silent no-op. |
@@ -185,6 +191,9 @@ Everything above reads someone else's content. These read and generate for **you
185
191
  | `get_scheduled_posts` | free | Your own scheduled and draft posts in the content pipeline — title, status, scheduled time, approval status. What is queued to publish. |
186
192
  | `get_post_performance` | free | Your own already-published posts with their engagement counters — views, likes, comments, shares, platform, post date. The raw performance history, not an interpretation of it; pair with `growth_brief` for that. |
187
193
  | `get_video_stats` | free | Your own most recently synced video performance stats across every connected creator — views, likes, comments, shares, plus a running total. Reads the last sync; does not trigger a new one. |
194
+ | `get_google_analytics` | free | Your product's GA4 sync **as of its last sync into nooticr, not live** — property, date range, rows synced. The metrics feed `get_post_performance` and `growth_brief`. |
195
+ | `get_search_console_data` | free | Search Console clicks, impressions and top queries, as of the last sync — whether people search for what your content is about. |
196
+ | `get_posthog_analytics` | free | Your PostHog pageview trend, as of the last sync — line a post's date up against traffic to your site. |
188
197
  | `get_content_plan` | free | The saved weekly content plan for a product, if one has been generated. `plan: null` when none has. |
189
198
  | `get_brand_playbook` | free | Your product's brand playbook — name, description and the playbook text — if one has been configured, in the dashboard or by `analyze_product`. Read-only; `available: false` when none exists. |
190
199
  | `review_post` | free | Score a draft before you publish it — hook strength, an A/B hook comparison, aesthetic and storytelling notes, rewritten hooks/captions. Never billed, same as the dashboard's own pre-publish review. |
@@ -192,7 +192,7 @@ export function categoryGuidance(opts) {
192
192
  lines.push("Judge only from what the reviews say. Where the evidence is thin, say so rather than", "producing a confident line the text does not support — a category read that overstates its", "basis is worse than a short one. Prices, ratings and mention counts are in the payload;", "do not re-derive them by hand.");
193
193
  if (insightsTool) {
194
194
  lines.push("");
195
- lines.push("To show the result in the conversation — products, their reviews, and your read side by", `side — call ${insightsTool} with what you concluded. It costs nothing and`, "makes no further requests.");
195
+ 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.");
196
196
  }
197
197
  return lines.join("\n");
198
198
  }
@@ -390,14 +390,19 @@ export function registerAmazonTools(server, makeClient) {
390
390
  description: "Display the category read you produced from scan_amazon_category: purchase drivers, " +
391
391
  "barriers, what each brand does well, the gaps, and the positioning angles — drawn beside " +
392
392
  "the listings and their reviews, so a person can click a product and check any claim " +
393
- "against the text it came from. Free, and makes no requests: it only draws what you pass " +
394
- "it, attributed to you rather than presented as a nooticr rating of anyone's product. " +
393
+ "against the text it came from. Pass the scanId: the listings are then re-read from the " +
394
+ "scan itself (free) rather than taken from you, so do not re-send them, and any ASIN you " +
395
+ "cite that the scan does not contain is flagged on the card. Without a scanId, listings " +
396
+ "you pass are drawn marked as not checked against a scan. Free. Your read is attributed " +
397
+ "to you rather than presented as a nooticr rating of anyone's product. " +
395
398
  "Call this after you have read the reviews, not instead of reading them.",
396
399
  annotations: {
397
400
  readOnlyHint: true,
398
401
  destructiveHint: false,
399
402
  idempotentHint: true,
400
- // Draws what it is given; reaches nothing.
403
+ // Draws what it is given. Its one request re-reads a scan the caller
404
+ // already ran, from nooticr's own job store with no wait, so it
405
+ // fetches nothing new from the marketplace (#107).
401
406
  openWorldHint: false,
402
407
  },
403
408
  outputSchema: OUTPUT_SCHEMAS.show_amazon_category_insights,
@@ -457,15 +462,17 @@ export function registerAmazonTools(server, makeClient) {
457
462
  products: z
458
463
  .array(anyObject())
459
464
  .optional()
460
- .describe("The listings from the scan, passed straight through so the view can draw them."),
461
- rollup: anyObject().optional().describe("The rollup from the scan, passed straight through."),
462
- scanId: z.string().optional().describe("The scan this reads, for the record."),
465
+ .describe("Only when there is no scanId. With one, the listings are re-read from the scan and anything here is ignored."),
466
+ rollup: anyObject().optional().describe("Only when there is no scanId; re-read from the scan otherwise."),
467
+ scanId: z
468
+ .string()
469
+ .optional()
470
+ .describe("The scan this reads. Pass it: it is what lets the card draw the collector's own listings."),
463
471
  })
464
472
  .passthrough(),
465
- }, async (args) => {
466
- const products = Array.isArray(args.products)
467
- ? args.products
468
- : [];
473
+ }, async (args, extra) => {
474
+ const checked = await rehydrate(args, () => makeClient({ ...extra, arguments: args }));
475
+ const products = checked.products;
469
476
  const counts = {
470
477
  drivers: Array.isArray(args.drivers) ? args.drivers.length : 0,
471
478
  barriers: Array.isArray(args.barriers) ? args.barriers.length : 0,
@@ -475,7 +482,8 @@ export function registerAmazonTools(server, makeClient) {
475
482
  const text = `Category read for ${String(args.category ?? "this category")}: ${counts.drivers} driver(s), ` +
476
483
  `${counts.barriers} barrier(s), ${counts.gaps} gap(s), ${counts.positioning} positioning angle(s)` +
477
484
  `${products.length ? `, drawn beside ${products.length} listing(s)` : ""}.` +
478
- (args.summary ? ` ${String(args.summary)}` : "");
485
+ (args.summary ? ` ${String(args.summary)}` : "") +
486
+ ` ${checked.verification.note}`;
479
487
  return {
480
488
  content: [{ type: "text", text }],
481
489
  structuredContent: {
@@ -489,14 +497,77 @@ export function registerAmazonTools(server, makeClient) {
489
497
  gaps: args.gaps ?? [],
490
498
  positioning: args.positioning ?? [],
491
499
  products,
492
- rollup: args.rollup ?? {},
500
+ rollup: checked.rollup,
493
501
  scanId: args.scanId ?? null,
494
- // Nothing was fetched, so nothing was charged.
502
+ verification: checked.verification,
503
+ // The one read is amazon_scan_status, which is free by design.
495
504
  mcpCredits: { cost: 0 },
496
505
  },
497
506
  };
498
507
  });
499
508
  }
509
+ /**
510
+ * The listings a category read is drawn beside, from the scan rather than
511
+ * from the model (#107).
512
+ *
513
+ * The card exists so a person can check a claim against the text it came
514
+ * from, which only works if the text is the collector's. A model that dropped
515
+ * three listings, mis-copied a price or invented a competitor used to produce
516
+ * a card identical to a truthful one. With a scanId the listings are re-read
517
+ * from the scan, free and with no wait, and whatever the model sent is only
518
+ * compared against them. Without one, or when the re-read fails, what the
519
+ * model sent is drawn and marked unverified rather than refused: a card with a
520
+ * visible caveat is more use to a person than no card.
521
+ */
522
+ export async function rehydrate(args, client) {
523
+ const sent = Array.isArray(args.products) ? args.products : [];
524
+ const sentRollup = (args.rollup ?? {});
525
+ const scanId = typeof args.scanId === "string" ? args.scanId.trim() : "";
526
+ const unverified = (note) => ({
527
+ products: sent,
528
+ rollup: sentRollup,
529
+ verification: { status: "unverified", note },
530
+ });
531
+ if (!scanId) {
532
+ return unverified(sent.length
533
+ ? "No scanId was passed, so these listings are as re-sent and were not checked against a scan."
534
+ : "No scanId was passed, so there are no listings to draw beside this read.");
535
+ }
536
+ let payload;
537
+ try {
538
+ const res = await (await client()).callTool("amazon_scan_status", { scanId, waitSeconds: 0 });
539
+ payload = structured(res);
540
+ }
541
+ catch (err) {
542
+ const why = err instanceof Error ? err.message : String(err);
543
+ return unverified(`Scan ${scanId} could not be re-read (${why}), so these listings are as re-sent and were not checked.`);
544
+ }
545
+ const products = normaliseProducts(payload.products);
546
+ const ids = new Set(products.map((p) => String(p.asin ?? "")).filter(Boolean));
547
+ const idOf = (p) => String([p.asin, p.sku, p.pid, p.goods_id, p.item_id, p.product_id, p.id].find((v) => typeof v === "string" && v) ?? "");
548
+ const notInScan = [...new Set(sent.map(idOf).filter((id) => id && !ids.has(id)))];
549
+ const cited = Array.isArray(args.strengths)
550
+ ? args.strengths.map((s) => String(s.asin ?? "")).filter(Boolean)
551
+ : [];
552
+ const unknownAsins = [...new Set(cited.filter((a) => !ids.has(a)))];
553
+ const scanComplete = payload.complete !== false;
554
+ const flags = [
555
+ notInScan.length ? `${notInScan.length} listing(s) you sent are not in the scan (${notInScan.join(", ")}) and are not drawn.` : "",
556
+ unknownAsins.length ? `ASIN(s) cited in strengths but not in the scan: ${unknownAsins.join(", ")}.` : "",
557
+ scanComplete ? "" : "The scan is still running, so the set drawn is what it had collected so far.",
558
+ ].filter(Boolean);
559
+ return {
560
+ products,
561
+ rollup: (payload.rollup ?? {}),
562
+ verification: {
563
+ status: "verified",
564
+ note: [`The ${products.length} listing(s) drawn are the scan's own, re-read from ${scanId}.`, ...flags].join(" "),
565
+ scanComplete,
566
+ notInScan,
567
+ unknownAsins,
568
+ },
569
+ };
570
+ }
500
571
  /**
501
572
  * One scan result, shaped for both channels.
502
573
  *
@@ -0,0 +1,24 @@
1
+ /**
2
+ * nooticr-server tools this public surface deliberately does not register.
3
+ *
4
+ * `POST /mcp` also serves the dashboard's internal copilot, so it carries
5
+ * tools that have no business in a Claude or ChatGPT host. Absence alone
6
+ * reads the same as "nobody got round to it", so each exclusion is written
7
+ * down with its reason (#106). A tool leaving this list for `TOOL_NAMES` is a
8
+ * product decision, not a cleanup; tests/backend-only.test.ts keeps the two
9
+ * lists disjoint.
10
+ */
11
+ export const BACKEND_ONLY_TOOLS = {
12
+ get_watchlist_baseline: "Plumbing for BackendWatchStore, which reads a creator's baseline itself; a host has nothing to do with the raw row.",
13
+ advance_watchlist_baseline: "Plumbing for BackendWatchStore; catch_up_watchlist advances the baseline after the user confirms, so a host never needs to.",
14
+ buy_nooticr_credits: "No purchase path is exposed to a model (#100). Credits are topped up on the nooticr website.",
15
+ overlay_bake: "Rendering and publishing stay in the dashboard (#92).",
16
+ bake_job_status: "Rendering and publishing stay in the dashboard (#92).",
17
+ compose_sequence: "Rendering and publishing stay in the dashboard (#92).",
18
+ spawn_variants: "Rendering and publishing stay in the dashboard (#92).",
19
+ enqueue_publish_job: "Rendering and publishing stay in the dashboard (#92).",
20
+ schedule_post: "Rendering and publishing stay in the dashboard (#92).",
21
+ get_batch_history: "History of the dashboard's compose batches, which only exist once rendering does; stays with #92.",
22
+ get_latest_analysis: "Returns the dashboard's stored AI fleet-analysis verdict; the public surface sells the evidence, not a judgement of ours.",
23
+ brand_watch_history: "Not a deliberate exclusion: a candidate for the public surface once it has a trend view to draw. Listed so it reads as pending, not forgotten.",
24
+ };
@@ -44,8 +44,10 @@ export function registerConnectionTools(server, makeClient) {
44
44
  "allowed to do — read the account, publish a post, manage comments. Each answer is yes, " +
45
45
  "no, or unknown; unknown means the grant predates scope recording, so treat it as \"try " +
46
46
  "it\", not as a refusal. Also returns which platforms can be connected at all, which is a " +
47
- "smaller set than the networks nooticr can read. Call this before promising that " +
48
- "something can be posted or replied to. No cost to call.",
47
+ "smaller set than the networks nooticr can read. \"Manage comments\" reports what the " +
48
+ "platform granted, not something nooticr does: no nooticr tool posts, replies to or " +
49
+ "moderates a comment, so a yes there never means a drafted reply will be sent. Call this " +
50
+ "before promising that something can be posted or replied to. No cost to call.",
49
51
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
50
52
  inputSchema: z.object({}).strict(),
51
53
  outputSchema: OUTPUT_SCHEMAS.list_social_connections,
@@ -48,11 +48,37 @@ const ITEM_CHARS = 600;
48
48
  const DIGEST_CHARS = 6000;
49
49
  /** How many items of one kind are worth rendering before the count matters more than the detail. */
50
50
  const MAX_ITEMS = 25;
51
+ function budget(verbatim) {
52
+ return verbatim
53
+ ? { item: 20_000, reply: 20_000, replies: 1_000, field: 4_000, digest: 200_000, items: 500, clipped: false }
54
+ : { item: ITEM_CHARS, reply: 240, replies: 4, field: 160, digest: DIGEST_CHARS, items: MAX_ITEMS, clipped: false };
55
+ }
51
56
  const str = (v) => (typeof v === "string" ? v : "");
52
57
  const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
53
- function clamp(text, max = ITEM_CHARS) {
58
+ function clamp(text, max, b) {
54
59
  const flat = text.replace(/\s+/g, " ").trim();
55
- return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
60
+ if (flat.length <= max)
61
+ return flat;
62
+ if (b)
63
+ b.clipped = true;
64
+ return `${flat.slice(0, max - 1)}…`;
65
+ }
66
+ /**
67
+ * The picture to embed beside a row, proxied where one exists (#91).
68
+ *
69
+ * A report writer on a host that renders the view gets only this text block,
70
+ * so without it the model had the review text and the price and no picture
71
+ * to put next to them. The proxy URL comes first for the reason
72
+ * `normaliseProducts` gives: the raw CDN link is signed, short-lived and
73
+ * Referer-gated, so pasted into a document it dies within the hour.
74
+ */
75
+ function media(row) {
76
+ for (const k of ["thumbnailProxyUrl", "imageProxyUrl", "image", "coverProxyUrl", "thumbnailUrl", "coverUrl"]) {
77
+ const v = row[k];
78
+ if (typeof v === "string" && /^https?:\/\//.test(v))
79
+ return v;
80
+ }
81
+ return "";
56
82
  }
57
83
  /** `12.3k`, because a raw 12345 beside four other numbers is harder to read than it looks. */
58
84
  function compactCount(n) {
@@ -82,7 +108,7 @@ function stats(row) {
82
108
  * for qualifying posts to be reported "with their permalink, so they can
83
109
  * actually be replied to". A digest without links cannot answer that.
84
110
  */
85
- function postLine(row) {
111
+ function postLine(row, b) {
86
112
  const head = [
87
113
  str(row.platform) || null,
88
114
  str(row.creatorHandle) ? `@${str(row.creatorHandle)}` : null,
@@ -92,20 +118,22 @@ function postLine(row) {
92
118
  ]
93
119
  .filter(Boolean)
94
120
  .join(" · ");
95
- const text = clamp(str(row.caption) || str(row.title) || str(row.text));
121
+ const text = clamp(str(row.caption) || str(row.title) || str(row.text), b.item, b);
96
122
  const url = str(row.externalUrl) || str(row.url);
123
+ const picture = media(row);
97
124
  // Replies under the post, when something opened it. Nested one level down,
98
125
  // which is exactly where #59 could come back without the chain-map gate
99
126
  // noticing: `posts` is rendered, so the check is satisfied while the thing
100
127
  // that was paid for — the comment that says "same here, this is my problem"
101
128
  // — stays in the payload only.
102
129
  const replies = Array.isArray(row.commentSample) ? row.commentSample : [];
103
- const shown = replies.slice(0, 4).map((c) => ` - ${clamp(commentLine(c), 240)}`);
130
+ const shown = replies.slice(0, b.replies).map((c) => ` - ${clamp(commentLine(c, b), b.reply, b)}`);
104
131
  if (replies.length > shown.length) {
132
+ b.clipped = true;
105
133
  shown.push(` - (${replies.length - shown.length} more under this post)`);
106
134
  }
107
135
  const readNothing = row.commentsRead === 0 ? " - (this thread would not open — not that nobody replied)" : "";
108
- return [head && `[${head}]`, text, url, ...shown, readNothing]
136
+ return [head && `[${head}]`, text, url, picture && `image: ${picture}`, ...shown, readNothing]
109
137
  .filter(Boolean)
110
138
  .join("\n ");
111
139
  }
@@ -116,16 +144,16 @@ function postLine(row) {
116
144
  * classification per comment and `show_comment_review` takes those ids back.
117
145
  * A digest that renders the text and drops the id breaks the next call.
118
146
  */
119
- function commentLine(row) {
147
+ function commentLine(row, b) {
120
148
  const id = str(row.id);
121
149
  const who = str(row.author) || str(row.username);
122
150
  const likes = num(row.likes);
123
151
  const head = [id || null, who ? `@${who}` : null, likes ? `${compactCount(likes)} likes` : null]
124
152
  .filter(Boolean)
125
153
  .join(" · ");
126
- return [head && `[${head}]`, clamp(str(row.text) || str(row.comment))].filter(Boolean).join("\n ");
154
+ return [head && `[${head}]`, clamp(str(row.text) || str(row.comment), b.item, b)].filter(Boolean).join("\n ");
127
155
  }
128
- function creatorLine(row) {
156
+ function creatorLine(row, b) {
129
157
  const head = [
130
158
  str(row.username) ? `@${str(row.username)}` : str(row.handle) ? `@${str(row.handle)}` : null,
131
159
  str(row.platform) || null,
@@ -134,7 +162,7 @@ function creatorLine(row) {
134
162
  ]
135
163
  .filter(Boolean)
136
164
  .join(" · ");
137
- const why = clamp(str(row.why) || str(row.bio) || str(row.verdict));
165
+ const why = clamp(str(row.why) || str(row.bio) || str(row.verdict), b.item, b);
138
166
  const links = Array.isArray(row.links)
139
167
  ? row.links
140
168
  .map((l) => str(l.url))
@@ -150,11 +178,34 @@ function runLine(row) {
150
178
  const reported = num(row.reported);
151
179
  return `${when || "(undated)"} · found ${found ?? "?"} · reported ${reported ?? "?"}`;
152
180
  }
181
+ /**
182
+ * One marketplace listing, with its picture (#91).
183
+ *
184
+ * Products used to fall to `genericLine`, whose first eight scalar fields ran
185
+ * out before `image` — so the one listing field a report most wants to embed
186
+ * never reached the model.
187
+ */
188
+ function productLine(row, b) {
189
+ const rating = num(row.rating);
190
+ const count = num(row.ratingCount);
191
+ const head = [
192
+ str(row.asin) || null,
193
+ str(row.brand) || null,
194
+ str(row.price) || null,
195
+ rating != null ? `${rating}★${count ? ` from ${compactCount(count)} ratings` : ""}` : null,
196
+ ]
197
+ .filter(Boolean)
198
+ .join(" · ");
199
+ const picture = media(row);
200
+ return [head && `[${head}]`, clamp(str(row.title), b.item, b), str(row.url), picture && `image: ${picture}`]
201
+ .filter(Boolean)
202
+ .join("\n ");
203
+ }
153
204
  /** Everything with no shape of its own: the short scalar fields, in order. */
154
- function genericLine(row) {
205
+ function genericLine(row, b) {
155
206
  const pairs = Object.entries(row)
156
207
  .filter(([, v]) => typeof v === "string" || typeof v === "number" || typeof v === "boolean")
157
- .map(([k, v]) => `${k}: ${clamp(String(v), 160)}`)
208
+ .map(([k, v]) => `${k}: ${clamp(String(v), b.field, b)}`)
158
209
  .slice(0, 8);
159
210
  return pairs.join(" · ");
160
211
  }
@@ -166,6 +217,7 @@ function genericLine(row) {
166
217
  */
167
218
  const SHAPES = [
168
219
  { key: "posts", label: "post", line: postLine },
220
+ { key: "products", label: "listing", line: productLine },
169
221
  // The baseline a post is scored against, and the asks a niche sweep found.
170
222
  // Both are posts in all but name, and both are the material their tool's
171
223
  // guidance reasons over — `why_did_this_underperform` cannot say "this is an
@@ -237,20 +289,24 @@ function unclaimedShapes(payload) {
237
289
  })
238
290
  .map(([key]) => ({ key, label: key.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase(), line: genericLine }));
239
291
  }
240
- export function evidenceDigest(payload) {
292
+ export function evidenceDigest(payload, opts = {}) {
241
293
  const blocks = [];
242
294
  let spent = 0;
295
+ const b = budget(opts.verbatim === true);
296
+ const elsewhere = opts.recover
297
+ ? `the rest are in the structured payload and in the view; to read them here, ${opts.recover}`
298
+ : "the rest are in the structured payload and in the view";
243
299
  for (const shape of [...SHAPES, ...unclaimedShapes(payload)]) {
244
300
  const rows = payload[shape.key];
245
301
  if (!Array.isArray(rows) || rows.length === 0)
246
302
  continue;
247
- if (spent >= DIGEST_CHARS)
303
+ if (spent >= b.digest)
248
304
  break;
249
305
  const shown = [];
250
- for (const row of rows.slice(0, MAX_ITEMS)) {
251
- if (spent >= DIGEST_CHARS)
306
+ for (const row of rows.slice(0, b.items)) {
307
+ if (spent >= b.digest)
252
308
  break;
253
- const line = shape.line((row ?? {}));
309
+ const line = shape.line((row ?? {}), b);
254
310
  if (!line.trim())
255
311
  continue;
256
312
  shown.push(line);
@@ -264,10 +320,17 @@ export function evidenceDigest(payload) {
264
320
  const total = rows.length;
265
321
  const plural = total === 1 || shape.label.endsWith("s") ? shape.label : `${shape.label}s`;
266
322
  const header = shown.length < total
267
- ? `${shown.length} of ${total} ${plural} (the rest are in the structured payload and in the view):`
323
+ ? `${shown.length} of ${total} ${plural} (${elsewhere}):`
268
324
  : `${total} ${plural}:`;
269
325
  blocks.push([header, ...shown.map((l, i) => `${i + 1}. ${l}`)].join("\n"));
270
326
  }
327
+ // A clipped item ends in "…", which a model can miss and then quote as if
328
+ // whole. Said once, with the way to get the full text when there is one.
329
+ if (b.clipped && blocks.length) {
330
+ blocks.push(opts.recover
331
+ ? `Some text above was shortened (it ends in "…"). Do not quote a shortened line as complete; to get every item whole, ${opts.recover}.`
332
+ : `Some text above was shortened (it ends in "…"). Do not quote a shortened line as complete.`);
333
+ }
271
334
  return blocks.join("\n\n");
272
335
  }
273
336
  /**
@@ -276,7 +339,7 @@ export function evidenceDigest(payload) {
276
339
  * Guidance first: it is the instruction, and a model that reads the material
277
340
  * before knowing what it is for reads it twice.
278
341
  */
279
- export function withEvidence(guidance, payload) {
280
- const digest = evidenceDigest(payload);
342
+ export function withEvidence(guidance, payload, opts = {}) {
343
+ const digest = evidenceDigest(payload, opts);
281
344
  return digest ? `${guidance}\n\n---\n\n${digest}` : guidance;
282
345
  }