@nooticr/mcp 1.26.25 → 1.26.27

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
@@ -93,7 +93,7 @@ them caps that fan-out with an argument.
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
95
  | `discover_sounds` | 2 | Trending audio with playable previews. Sound is a major ranking signal on TikTok. |
96
- | `discover_hashtags` | 2 | Trending hashtags with volumes and whether each is rising, cooling or steady. |
96
+ | `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
97
  | `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
98
  | `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
99
  | `watch_creator` | free | Add a creator to your watchlist. Stores the handle only — nothing is fetched. |
@@ -101,6 +101,7 @@ them caps that fan-out with an argument.
101
101
  | `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
102
  | `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
103
  | `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. |
104
+ | `mention_trend` | free | **What a watch has seen over time**, which nothing else here answers — every other read is about now. One point per run: when it ran, how many mentions it found, how many were new, and both counts per network, so "which network is growing" and "did it go quiet or did we stop looking" come apart. On a competitor watch each point carries the median that run measured, which turns "they beat their own median" into "and that median has doubled". Plus the mentions more than one run has seen. Free because the sweeps were billed when they ran; charging again to read what was already paid for is the thing it fixes. |
104
105
  | `stop_brand_watch` | free | Stop a watch by `watchId` or `term`. Immediate — the next run does not happen and nothing more is charged. |
105
106
  | `niche_report` | 2 | Recent posts in a niche with their stats, so the dominant formats, hook patterns and the gaps nobody fills can be read off them. One `discover_social_posts` call. Use when deciding what to make. |
106
107
  | `analyze_creator_profile` | 2 | A creator's recent posts with their stats — the material of a teardown: niche, themes, hook formula, what over- and underperforms, who the audience is. One `get_user_posts` call. |
@@ -112,6 +113,7 @@ them caps that fan-out with an argument.
112
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. |
113
114
  | `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. |
114
115
  | `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
+ | `show_trend` | free | Draws the trend your model read out of `mention_trend`. `tooShort` marks a series with too few points to call a direction, and `edgeIsRecordStart` marks a left edge that is where the record begins rather than where the conversation did — a chart hiding either is the mistake this view could otherwise make on your behalf. Makes no requests. |
115
117
  | `track_competitor` | 2 | What a creator shipped, and which of it beat **their own** median rather than a raw view count that mostly measures follower count. One post list, whatever the window. If they are on your watchlist it also marks what is new since your last check and moves that marker forward — its own marker, not the one `catch_up_watchlist` keeps. |
116
118
  | `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. |
117
119
  | `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. |
@@ -136,7 +138,6 @@ them caps that fan-out with an argument.
136
138
  | Tool | Credits | What it is for |
137
139
  |------|---------|----------------|
138
140
  | `check_nooticr_credits` | free | Balance and billing URL. |
139
- | `buy_nooticr_credits` | free | A Stripe Checkout URL for a credit pack. Credits land automatically after payment. |
140
141
  | `nooticr_login` | free | Re-link the account when a call fails with an authentication error. |
141
142
  | `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. |
142
143
  | `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. |
@@ -485,7 +486,7 @@ and text** posts.
485
486
  lookup or transcript, 2 for discovery and for a tool that makes one fetch, 3
486
487
  for the two that fetch frames *and* transcript. Every tool bills from the
487
488
  first call. Top up via
488
- `buy_nooticr_credits`, `check_nooticr_credits`, or the nooticr dashboard at
489
+ `check_nooticr_credits`, or the nooticr dashboard at
489
490
  `https://nooticr.com/settings?tab=billing`.
490
491
  - **Expired refresh token**: the stored refresh token was rejected by the
491
492
  nooticr server. Run `npx @nooticr/mcp login` again to re-authenticate.
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { z } from "zod";
16
16
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
17
+ import { viewMeta } from "./view-meta.js";
17
18
  import { BACKEND_CALL_CREDITS, confirmSpend, declinedResult, searchMentionsCost } from "./spend.js";
18
19
  /** Exported so prompts.ts's completion offers exactly what create_brand_watch accepts. */
19
20
  export const CADENCES = ["hourly", "every_6_hours", "every_12_hours", "daily", "weekly"];
@@ -136,6 +137,133 @@ async function gateRecurringCharge(server, args) {
136
137
  return null;
137
138
  }
138
139
  /** Same shaping every other proxied tool uses: text block plus structured payload. */
140
+ /**
141
+ * What to do with a run series.
142
+ *
143
+ * Three ways a reader gets this wrong, and all three produce a confident
144
+ * sentence about a change that did not happen:
145
+ *
146
+ * - Two points are not a trend. A watch that has run twice can only say what
147
+ * the second run found, and the difference between two sweeps of scraped
148
+ * data is mostly the scrape.
149
+ * - The start of the window is not the start of the conversation. It is the
150
+ * edge of what is kept, or the day the watch was created — a flat left
151
+ * edge reads as silence and is neither.
152
+ * - Volume found and volume mailed answer different questions. A run that
153
+ * found forty and mailed none is a conversation that has stopped moving,
154
+ * not one that has stopped.
155
+ */
156
+ /**
157
+ * Which networks moved, and which way — computed, not left to the reader.
158
+ *
159
+ * `runs` comes back newest-first, which is the order it is read in and the
160
+ * wrong order to subtract in. Naming the direction here removes the one step
161
+ * where an off-by-one-end error produces a confident sentence about a change
162
+ * that went the other way.
163
+ */
164
+ function perPlatformDirection(runs) {
165
+ const at = (r) => String(r.ranAt ?? "");
166
+ const chronological = [...runs].sort((a, b) => (at(a) < at(b) ? -1 : 1));
167
+ const foundIn = (r, platform) => {
168
+ const per = (r?.perPlatform ?? {});
169
+ const v = per[platform]?.found;
170
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
171
+ };
172
+ const first = chronological[0];
173
+ const last = chronological[chronological.length - 1];
174
+ const platforms = new Set();
175
+ for (const r of chronological) {
176
+ for (const k of Object.keys((r.perPlatform ?? {})))
177
+ platforms.add(k);
178
+ }
179
+ const moves = [];
180
+ for (const platform of platforms) {
181
+ const from = foundIn(first, platform);
182
+ const to = foundIn(last, platform);
183
+ if (from === null || to === null || from === to)
184
+ continue;
185
+ moves.push({ platform, from, to, delta: to - from });
186
+ }
187
+ if (!moves.length) {
188
+ return ("No network's count differs between the earliest and latest sweep in this window, so " +
189
+ "whatever moved, moved inside it rather than across it — look at the points themselves.");
190
+ }
191
+ moves.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
192
+ const said = moves
193
+ .slice(0, 4)
194
+ .map((m) => `${m.platform} ${m.delta > 0 ? "rose" : "fell"} from ${m.from} to ${m.to} ` +
195
+ `(${m.delta > 0 ? "+" : ""}${m.delta})`)
196
+ .join(", ");
197
+ return (`Earliest to latest sweep in this window: ${said}. Those directions are computed here on ` +
198
+ "purpose — `runs` is newest-first, so subtracting the ends by hand is where a real reading " +
199
+ "of this went backwards. Use these rather than re-deriving them.");
200
+ }
201
+ function trendGuidance(sc, runs) {
202
+ const n = runs.length;
203
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
204
+ const term = typeof sc.term === "string" && sc.term ? `"${sc.term}"` : "this watch";
205
+ const kind = sc.kind === "competitor" ? "competitor" : "mentions";
206
+ const lines = [];
207
+ if (!n) {
208
+ lines.push(`No runs recorded for ${term} in that window. A watch records a point every time it ` +
209
+ "sweeps, so this means it has not run yet rather than that nobody has said anything — " +
210
+ "say which, and check `nextRun` on list_brand_watches. Nothing was charged.");
211
+ return lines.join("\n");
212
+ }
213
+ const windowDays = num(sc.windowDays);
214
+ lines.push(`${n} run${n === 1 ? "" : "s"} of ${term}` +
215
+ (windowDays ? ` in the last ${windowDays} days` : "") +
216
+ `, newest first. Each point is one sweep: \`found\` is everything it saw, \`reported\` is ` +
217
+ "the subset that was new, and `perPlatform` splits both by network.");
218
+ if (n < 3) {
219
+ lines.push("", `Two points are not a trend, and ${n} ${n === 1 ? "is" : "are"} what you have. Report what ` +
220
+ "the runs found and say the series is too short to call a direction — the difference " +
221
+ "between two sweeps of scraped data is mostly the scrape.");
222
+ }
223
+ else {
224
+ lines.push("", "Read it yourself: the shape of `found` over time is the volume question, and `perPlatform` " +
225
+ "is the one underneath it — a total that holds steady while one network doubles and " +
226
+ "another empties is the finding, and the total hides it.", "",
227
+ // Stated rather than left to be derived, because deriving it means
228
+ // knowing which end of the array is which — and `runs` is newest-first.
229
+ // Reading it by hand got the direction exactly backwards on the first
230
+ // attempt ("TikTok fell from 5 to 12"), which is a mistake a model makes
231
+ // just as easily and reports with the same confidence.
232
+ perPlatformDirection(runs), "", "`found` and `reported` are different questions and usually move differently. Flat `found` " +
233
+ "with falling `reported` is a conversation that has stopped moving rather than stopped. A " +
234
+ "spike in `found` that is almost all already-seen is one post being re-surfaced by a " +
235
+ "platform, not new interest.");
236
+ }
237
+ if (kind === "competitor") {
238
+ const newest = num(sc.medianViews?.newest);
239
+ const oldest = num(sc.medianViews?.oldest);
240
+ lines.push("", "This is a competitor watch, so each point carries `medianViews` — the baseline that run " +
241
+ "measured. That is the second axis: a creator can beat their own median every week while " +
242
+ "the median itself is falling, which is a shrinking account having good weeks." +
243
+ (newest !== null && oldest !== null
244
+ ? ` Here it went from ${oldest.toLocaleString("en-US")} to ${newest.toLocaleString("en-US")}.`
245
+ : ""));
246
+ }
247
+ const retained = num(sc.retainedDays);
248
+ const created = typeof sc.watchCreatedAt === "string" ? sc.watchCreatedAt : null;
249
+ lines.push("", "Do not read the left edge as the start of anything." +
250
+ (retained ? ` Runs are kept for ${retained} days.` : "") +
251
+ (created ? ` This watch was created ${created.slice(0, 10)}.` : "") +
252
+ " A series that starts flat starts where the record does, which is not the same as a " +
253
+ "period when nobody was talking.");
254
+ const recurring = Array.isArray(sc.recurring) ? sc.recurring : [];
255
+ if (recurring.length) {
256
+ lines.push("", `\`recurring\` lists the ${recurring.length} mention${recurring.length === 1 ? "" : "s"} more ` +
257
+ "than one run has seen, with how many runs saw each. These are fingerprints, not text: " +
258
+ "the mentions themselves are other people's writing and are deliberately not stored. A " +
259
+ "high `timesSeen` is a post that keeps resurfacing — worth naming as one thing rather " +
260
+ "than as repeated new interest.");
261
+ }
262
+ lines.push("", "This is free because the sweeps were already billed. Nothing here needs a further call to " +
263
+ "interpret it — read the numbers and say what changed, including that nothing did, if that " +
264
+ "is what they show.");
265
+ return lines.join("\n");
266
+ }
139
267
  function toResult(proxy) {
140
268
  const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
141
269
  return {
@@ -274,6 +402,70 @@ export function registerBrandWatch(server, makeClient) {
274
402
  return failed("list_brand_watches failed", err);
275
403
  }
276
404
  });
405
+ server.registerTool("mention_trend", {
406
+ title: "Mention Trend",
407
+ _meta: viewMeta("mention_trend"),
408
+ description: "What a watch has SEEN OVER TIME, which no other tool here can answer — every read on " +
409
+ "this surface answers about now, and until the backend started keeping run history each " +
410
+ "sweep threw its numbers away. Takes `watchId` (or `term`) and an optional `days`. " +
411
+ "Returns one point per run: when it ran, how many mentions it found, how many were new, " +
412
+ "and both counts per network — so \"which network is growing\" and \"did it go quiet, or " +
413
+ "did we stop looking\" are separable. On a competitor watch each point also carries the " +
414
+ "median that run measured, which is what turns \"they beat their own median\" into \"and " +
415
+ "that median has doubled\". Also returns the mentions more than one run has seen. " +
416
+ "FREE: the sweeps were billed when they ran, and charging again to read what was already " +
417
+ "paid for is the thing this fixes. " +
418
+ "Read the series yourself and say what changed — this returns numbers, not a verdict.",
419
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
420
+ inputSchema: z
421
+ .object({
422
+ watchId: z.string().optional().describe("The watch id from list_brand_watches."),
423
+ term: z
424
+ .string()
425
+ .optional()
426
+ .describe("The watched term, if you do not have the id. One of the two is required."),
427
+ days: z
428
+ .number()
429
+ .int()
430
+ .optional()
431
+ .describe("Window in days (default 90). Clamped to how long the backend keeps runs, which " +
432
+ "comes back as `retainedDays` — asking for more silently returns less, and a " +
433
+ "flat start would read as silence rather than as the edge of the record."),
434
+ })
435
+ .strict(),
436
+ outputSchema: OUTPUT_SCHEMAS.mention_trend,
437
+ }, async (args, extra) => {
438
+ const client = await makeClient({ ...extra, arguments: args });
439
+ if (!args.watchId && !args.term?.trim()) {
440
+ return {
441
+ content: [
442
+ {
443
+ type: "text",
444
+ text: "This needs a watch to read: pass `watchId` (from list_brand_watches) or the " +
445
+ "`term` a watch was created for. Nothing was fetched and nothing was charged.",
446
+ },
447
+ ],
448
+ structuredContent: { available: false, billable: false, mcpCredits: { cost: 0 } },
449
+ };
450
+ }
451
+ try {
452
+ const proxy = await client.callTool("brand_watch_history", { ...args });
453
+ const result = toResult(proxy);
454
+ const sc = (result.structuredContent ?? {});
455
+ const runs = Array.isArray(sc.runs) ? sc.runs : [];
456
+ // Guidance in the text block, evidence in the structured one — the
457
+ // pattern every reading tool here uses. What it has to prevent is a
458
+ // model reading two points as a trend, and reading the start of the
459
+ // retention window as the moment a conversation began.
460
+ return {
461
+ ...result,
462
+ content: [{ type: "text", text: trendGuidance(sc, runs) }],
463
+ };
464
+ }
465
+ catch (err) {
466
+ return failed("mention_trend failed", err);
467
+ }
468
+ });
277
469
  server.registerTool("stop_brand_watch", {
278
470
  title: "Stop Brand Watch",
279
471
  description: "Stop a scheduled brand-monitoring watch, by watchId or by term. Takes effect immediately: " +
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Trending hashtags for the nine networks that have no trend board.
3
+ *
4
+ * `discover_hashtags` reads TikTok's Creative Center, which is a real trend
5
+ * board: it knows post counts, view counts, and whether a tag is rising,
6
+ * cooling or steady. Nothing equivalent exists upstream for the other nine
7
+ * networks, so "what should I tag this?" was answerable on one network out of
8
+ * ten — while `repurpose_post` exists precisely to move a post between
9
+ * networks, and tagging is part of the conventions it cannot inform (issue #32).
10
+ *
11
+ * What is available is a niche sweep the caller is often paying for anyway.
12
+ * Counting tags across the captions it returns is a current, per-network answer
13
+ * drawn from real posts. It is weaker than a trend board and weaker in a
14
+ * specific way — a sample of one search has no rising/cooling signal, because
15
+ * there is no earlier sample to compare against — so the result says which
16
+ * source it came from rather than presenting the two as the same measurement.
17
+ * Same idea as `get_post_transcript` marking `source: "speech-to-text"`.
18
+ */
19
+ const NUM = /^-?\d+(\.\d+)?$/;
20
+ function views(post) {
21
+ const raw = post.views;
22
+ if (typeof raw === "number" && Number.isFinite(raw))
23
+ return raw;
24
+ if (typeof raw === "string" && NUM.test(raw.trim()))
25
+ return Number(raw.trim());
26
+ return null;
27
+ }
28
+ /**
29
+ * Tags in one post: the `hashtags` array when the network fills it, plus any
30
+ * `#tag` written in the caption.
31
+ *
32
+ * Both, not either. Instagram and TikTok populate `hashtags`; X, Reddit and
33
+ * LinkedIn frequently leave it empty while the caption is full of them, and a
34
+ * tally that trusted only the array returned nothing for exactly the networks
35
+ * this exists to serve.
36
+ */
37
+ /**
38
+ * Scripts where two characters is a word, not an abbreviation.
39
+ *
40
+ * The length floor below exists to keep "#1" and "#ad" off the top of a
41
+ * frequency count, and a flat "three or more" is the wrong way to spell that:
42
+ * 护肤 is "skincare", スキンケア's stem is two kana, and both are exactly the
43
+ * networks this widening was for. The first version of this dropped them
44
+ * silently — an empty answer for Weibo and Xiaohongshu that would have read as
45
+ * "this niche has no tags".
46
+ */
47
+ const IDEOGRAPHIC = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
48
+ /** Noise at the top of a frequency count, rather than an answer. */
49
+ function isNoise(tag) {
50
+ if (/\s/.test(tag) || !tag)
51
+ return true;
52
+ // A bare number is never the tag someone searched for.
53
+ if (/^\p{N}+$/u.test(tag))
54
+ return true;
55
+ return tag.length < (IDEOGRAPHIC.test(tag) ? 2 : 3);
56
+ }
57
+ export function tagsIn(post) {
58
+ const out = new Set();
59
+ const add = (raw) => {
60
+ const clean = String(raw ?? "")
61
+ .replace(/^#/, "")
62
+ .trim()
63
+ .toLowerCase();
64
+ if (!isNoise(clean))
65
+ out.add(clean);
66
+ };
67
+ if (Array.isArray(post.hashtags))
68
+ for (const tag of post.hashtags)
69
+ add(tag);
70
+ for (const field of [post.caption, post.title]) {
71
+ // Unicode letters and marks, so a Weibo or Xiaohongshu tag is not dropped.
72
+ // The floor is applied in `add`, not here, so one rule decides it.
73
+ for (const m of String(field ?? "").matchAll(/#([\p{L}\p{M}\p{N}_]{2,})/gu))
74
+ add(m[1]);
75
+ }
76
+ return [...out];
77
+ }
78
+ /** The median of a list, or null when there is nothing to take one of. */
79
+ function median(values) {
80
+ if (!values.length)
81
+ return null;
82
+ const sorted = [...values].sort((a, b) => a - b);
83
+ const mid = Math.floor(sorted.length / 2);
84
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
85
+ }
86
+ /**
87
+ * Rank the tags in a swept feed by how many posts carry them.
88
+ *
89
+ * Ties break on median views rather than total, so one viral post carrying a
90
+ * tag nobody else uses does not outrank a tag five posts share. `count` caps
91
+ * the output; a tag used by exactly one post is dropped, because a tag that
92
+ * appears once is that post's tag and not the niche's — the same reasoning
93
+ * `find_hook_pattern` applies to a template that fits one post.
94
+ */
95
+ export function deriveHashtags(posts, count = 20) {
96
+ const seen = new Map();
97
+ for (const post of posts) {
98
+ const v = views(post);
99
+ const url = typeof post.externalUrl === "string" ? post.externalUrl : null;
100
+ for (const tag of tagsIn(post)) {
101
+ const row = seen.get(tag) ?? { posts: 0, views: [], example: null };
102
+ row.posts += 1;
103
+ if (v !== null)
104
+ row.views.push(v);
105
+ row.example ??= url;
106
+ seen.set(tag, row);
107
+ }
108
+ }
109
+ return [...seen.entries()]
110
+ .filter(([, row]) => row.posts > 1)
111
+ .map(([hashtag, row]) => ({
112
+ hashtag,
113
+ posts: row.posts,
114
+ views: row.views.length ? row.views.reduce((a, b) => a + b, 0) : null,
115
+ medianViews: median(row.views),
116
+ example: row.example,
117
+ }))
118
+ .sort((a, b) => b.posts - a.posts || (b.medianViews ?? -1) - (a.medianViews ?? -1))
119
+ .slice(0, Math.max(1, count));
120
+ }
121
+ /**
122
+ * What the derived result says about itself.
123
+ *
124
+ * A caller handed a ranked list of tags will otherwise read it as a trend
125
+ * board, and act on a "rising" signal that was never measured. Naming the
126
+ * sample size is the honest version: five posts do not establish that a tag is
127
+ * popular, and the model is the one that has to decide whether to say so.
128
+ */
129
+ export function derivedNote(niche, platform, swept, found) {
130
+ if (!swept) {
131
+ return (`No posts came back for "${niche}" on ${platform}, so there is nothing to count tags in. ` +
132
+ "That is a coverage gap here, not evidence the niche is untagged — say so rather than " +
133
+ "reporting an empty answer as a finding.");
134
+ }
135
+ return (`Counted across ${swept} recent ${platform} post${swept === 1 ? "" : "s"} matching "${niche}" — ` +
136
+ `${found} tag${found === 1 ? "" : "s"} used by more than one of them, ranked by how many posts ` +
137
+ "carry each. " +
138
+ "This is a sample of one search, not a trend board: there is no rising/cooling signal here " +
139
+ "because there is no earlier sample to compare against, and only TikTok has a real one. " +
140
+ `With ${swept} posts, treat the tail of this list as noise, and say so instead of ranking it ` +
141
+ "confidently. The `example` on each tag is a post that used it, so any claim you make about a " +
142
+ "tag can be checked against a real post rather than taken from the count.");
143
+ }
@@ -299,6 +299,10 @@ export const LOADING_PLANS = {
299
299
  show_variants: { label: "Laying out the variants", kind: "list", n: 4, steps: [], free: true },
300
300
  show_repurposed_post: { label: "Laying out the rewrites", kind: "text", n: 1, steps: [], free: true },
301
301
  show_collab_shortlist: { label: "Drawing the shortlist", kind: "list", n: 5, steps: [], free: true },
302
+ show_trend: { label: "Drawing the trend", kind: "text", n: 1, steps: [], free: true },
303
+ // Reads stored aggregates the sweeps already paid for. Free, and no
304
+ // upstream step to wait on — the wait is a database read.
305
+ mention_trend: { label: "Reading the run history", kind: "text", n: 1, steps: [], free: true },
302
306
  prepare_handoff: { label: "Packaging the handoff", kind: "list", n: 4, steps: [], free: true },
303
307
  // ─── Own-account reads: nooticr's own stored rows ───
304
308
  get_scheduled_posts: { label: "Reading your pipeline", kind: "list", n: 5, steps: [], free: true },
@@ -343,7 +347,6 @@ export const LOADING_PLANS = {
343
347
  generate_captions: { label: "Writing the captions", kind: "text", n: 1, steps: [], planAi: true },
344
348
  // ─── Account and own-account: free, and free at a zero balance ───
345
349
  check_nooticr_credits: { label: "Checking your balance", kind: "text", n: 1, steps: [], free: true },
346
- buy_nooticr_credits: { label: "Opening checkout", kind: "text", n: 1, steps: [], free: true },
347
350
  nooticr_login: { label: "Getting a sign-in link", kind: "text", n: 1, steps: [], free: true },
348
351
  watch_creator: { label: "Adding to your watchlist", kind: "text", n: 1, steps: [], free: true },
349
352
  unwatch_creator: { label: "Removing from your watchlist", kind: "text", n: 1, steps: [], free: true },
@@ -704,9 +704,24 @@ export const OUTPUT_SCHEMAS = {
704
704
  hashtag: scalar(),
705
705
  posts: scalar(),
706
706
  views: scalar(),
707
- trend: scalar().describe("rising, cooling or steady."),
707
+ // Only the derived route computes these two: a trend board reports its
708
+ // own totals, a counted sample reports the middle post and one example
709
+ // so a claim about a tag can be checked against a real post.
710
+ medianViews: scalar().describe("Derived route only — views of the median post carrying it."),
711
+ example: scalar().describe("Derived route only — a post that used it."),
712
+ trend: scalar().describe("rising, cooling or steady. Trend board only; a sample has none."),
708
713
  url: scalar(),
709
714
  })).optional(),
715
+ // Which of the two routes answered, so a counted sample is not read as a
716
+ // trend board. Same marker as get_post_transcript's `source`.
717
+ source: scalar().describe('"trend-board" (tiktok) or "derived-from-sweep" (everywhere else).'),
718
+ platform: scalar(),
719
+ niche: scalar().describe("Derived route only — the sweep the tags were counted from."),
720
+ sweptPosts: scalar().describe("Derived route only — how many posts were counted."),
721
+ note: scalar().describe("Derived route only — the sample size and what it does not establish."),
722
+ available: scalar().describe("false when the network cannot be swept at all."),
723
+ billable: scalar(),
724
+ reason: scalar(),
710
725
  country: scalar(),
711
726
  days: scalar(),
712
727
  mcpCredits,
@@ -781,13 +796,8 @@ export const OUTPUT_SCHEMAS = {
781
796
  // so the description is what steers a reader off it.
782
797
  firstFreeRemaining: listOf(z.string())
783
798
  .describe("Superseded by firstFreeTools, which carries the same value. Kept for backward compatibility — read firstFreeTools."),
784
- billingUrl: scalar(),
785
799
  hint: scalar(),
786
800
  }),
787
- buy_nooticr_credits: open({
788
- checkoutUrl: scalar(),
789
- packs: listOf(open({})),
790
- }),
791
801
  nooticr_login: open({
792
802
  signedIn: scalar().describe("true when the session is already good and no link is needed."),
793
803
  loginUrl: scalar().describe("Only present when a sign-in is actually required."),
@@ -832,6 +842,55 @@ export const OUTPUT_SCHEMAS = {
832
842
  firstRun: scalar(),
833
843
  message: scalar(),
834
844
  }),
845
+ /**
846
+ * A run series. `runs` is newest first, and every derived number the caller
847
+ * might want is left underived on purpose — the model reads the series.
848
+ */
849
+ show_trend: open({
850
+ points: listOf(anyObject()),
851
+ term: scalar(),
852
+ metric: scalar(),
853
+ verdict: scalar(),
854
+ // Both caveats are fields rather than prose, so the view can draw them
855
+ // and cannot quietly omit them.
856
+ tooShort: scalar().describe("Too few points to call a direction."),
857
+ edgeIsRecordStart: scalar().describe("The left edge is the record's start, not the conversation's."),
858
+ trend: scalar().describe("The discriminator the view keys on."),
859
+ }),
860
+ mention_trend: open({
861
+ runs: listOf(open({
862
+ ranAt: scalar(),
863
+ found: scalar().describe("Everything that sweep saw."),
864
+ reported: scalar().describe("The subset that was new — a different question from found."),
865
+ perPlatform: anyObject().nullish().describe('{"tiktok":{"found":12,"reported":3},...}'),
866
+ medianViews: scalar().describe("Competitor watches only: the baseline that run measured."),
867
+ postsScored: scalar(),
868
+ costCredits: scalar(),
869
+ })).optional(),
870
+ watchId: scalar(),
871
+ kind: scalar(),
872
+ term: scalar(),
873
+ competitorHandle: scalar(),
874
+ platforms: listOf(z.string()),
875
+ windowDays: scalar(),
876
+ // Two numbers a caller cannot derive and will otherwise assume: how long
877
+ // anything is kept, and how long the watch has existed. A flat left edge
878
+ // is one of those, not a quiet period.
879
+ retainedDays: scalar(),
880
+ watchCreatedAt: scalar(),
881
+ runCount: scalar(),
882
+ found: anyObject().nullish().describe("newest and oldest in the window, so direction needs no array maths."),
883
+ medianViews: anyObject().nullish(),
884
+ recurring: listOf(open({
885
+ mentionKey: scalar().describe("A fingerprint. The text is deliberately not stored."),
886
+ timesSeen: scalar(),
887
+ firstReportedAt: scalar(),
888
+ lastSeenAt: scalar(),
889
+ })),
890
+ available: scalar(),
891
+ billable: scalar(),
892
+ mcpCredits,
893
+ }),
835
894
  list_brand_watches: open({
836
895
  watches: listOf(open({
837
896
  watchId: scalar(),
@@ -36,6 +36,28 @@ export const SEARCH_PLATFORMS = [
36
36
  * is a credit quoted for a call that never happens.
37
37
  */
38
38
  export const SPOKEN_PLATFORMS = ["tiktok", "youtube", "douyin"];
39
+ /**
40
+ * Platforms a niche sweep can reach — the server's DISCOVERABLE_PLATFORMS.
41
+ *
42
+ * Named here for the same reason as the list above: more than one place has to
43
+ * agree on it. `discover_hashtags` counts tags across a sweep for every network
44
+ * but TikTok, so this list decides both which platforms it accepts and what its
45
+ * refusal names as the alternatives. LinkedIn is the one absentee — a post URL
46
+ * and a handle's feed both work there, discovery does not — and a tool that
47
+ * accepted it would spend a credit to return nothing, which then reads as "this
48
+ * niche has no tags".
49
+ */
50
+ export const DISCOVERABLE_PLATFORMS = [
51
+ "youtube",
52
+ "tiktok",
53
+ "instagram",
54
+ "douyin",
55
+ "xiaohongshu",
56
+ "twitter",
57
+ "bilibili",
58
+ "reddit",
59
+ "weibo",
60
+ ];
39
61
  /** Two per network, except the one that costs an order of magnitude more upstream. */
40
62
  export const CREDITS_PER_NETWORK = 2;
41
63
  export const XIAOHONGSHU_CREDITS = 5;
@@ -81,8 +81,8 @@ export const TOOL_DEFINITIONS = [
81
81
  {
82
82
  name: "discover_hashtags",
83
83
  title: "Discover Hashtags",
84
- description: "Trending TikTok hashtags from the Creative Center trend board, with post counts, view counts and whether each is rising, cooling or steady. Filter by country and time window. Use to find what to tag, or to spot a wave early. Consumes 2 nooticr credits.",
85
- inputSchema: z.object({ country: z.string().optional().describe("2-letter country code (default US)."), days: z.number().int().optional().describe("Window in days: 7, 30 or 120 (default 7)."), count: z.number().int().optional().describe("Max hashtags (default 20)."), industryId: z.string().optional().describe("Optional TikTok industry id to filter by.") }).strict(),
84
+ description: "What to tag a post with, on any network that can be searched. TikTok is read from the Creative Center trend board: post counts, view counts, and whether each tag is rising, cooling or steady. The other eight — instagram, youtube, douyin, xiaohongshu, twitter, bilibili, reddit, weibo — have no trend board upstream, so the tags are counted across a live sweep of the niche you name, which needs `niche` set and returns no rising/cooling signal because there is no earlier sample to compare against. The result says which of the two it is in `source`; do not present a counted sample as a trend board. One network cannot be swept at all: linkedin. Filter by country and time window on TikTok. Consumes 2 nooticr credits either way. Use to find what to tag, or to spot a wave early.",
85
+ inputSchema: z.object({ platform: z.string().optional().describe("Network to tag for. Defaults to tiktok, the only one with a real trend board. Anything else counts tags across a sweep and needs `niche`. linkedin cannot be swept."), niche: z.string().optional().describe("Topic to sweep, required for every platform but tiktok — the tags are counted from the posts a search for this returns, so without it there is nothing to count."), country: z.string().optional().describe("2-letter country code (default US). TikTok only."), days: z.number().int().optional().describe("Window in days: 7, 30 or 120 (default 7). TikTok only."), count: z.number().int().optional().describe("Max hashtags (default 20)."), industryId: z.string().optional().describe("Optional TikTok industry id to filter by.") }).strict(),
86
86
  },
87
87
  {
88
88
  name: "analyze_post_fast",
@@ -189,13 +189,7 @@ export const TOOL_DEFINITIONS = [
189
189
  {
190
190
  name: "check_nooticr_credits",
191
191
  title: "Check Nooticr Credits",
192
- description: "Check your MCP credit balance, billing URL and pack size. New users get 20 free credits. No cost — call anytime to see remaining credits before running other tools. Use before a run of paid calls to confirm the balance covers it.",
193
- inputSchema: z.object({}).strict(),
194
- },
195
- {
196
- name: "buy_nooticr_credits",
197
- title: "Buy Nooticr Credits",
198
- description: "Buy an MCP credit pack via Stripe Checkout. Returns a secure checkout URL — open it in your browser to pay. Credits are added automatically after payment. Use when the balance is short and the user has agreed to top up. No cost to call.",
192
+ description: "Check your nooticr credit balance. No cost to call — call anytime to see remaining credits before running other tools. Nothing here sells or tops up credits: this server offers no purchase of any kind, so when the balance is short, say that it is and that topping up happens on the nooticr website, and do not offer a link or a price. Use before a run of paid calls to confirm the balance covers it.",
199
193
  inputSchema: z.object({}).strict(),
200
194
  },
201
195
  {
@@ -234,6 +228,18 @@ export const TOOL_DEFINITIONS = [
234
228
  description: "Every scheduled brand-monitoring watch this user has: term, networks, cadence, cost per run, credits spent so far, runs made, next run due, and whether it is stopped and why. Read before creating a watch — a second watch on the same term is a second recurring charge for the same answer. No cost to call.",
235
229
  inputSchema: z.object({}).strict(),
236
230
  },
231
+ {
232
+ name: "mention_trend",
233
+ title: "Mention Trend",
234
+ description: "What a watch has SEEN OVER TIME, which no other tool here can answer — every read on this surface answers about now, and until the backend started keeping run history each sweep threw its numbers away. Takes `watchId` (or `term`) and an optional `days`. Returns one point per run: when it ran, how many mentions it found, how many were new, and both counts per network. On a competitor watch each point also carries the median that run measured. Also returns the mentions more than one run has seen. FREE: the sweeps were billed when they ran. Read the series yourself and say what changed.",
235
+ inputSchema: z.object({ watchId: z.string().optional(), term: z.string().optional(), days: z.number().int().optional() }).strict(),
236
+ },
237
+ {
238
+ name: "show_trend",
239
+ title: "Show Trend",
240
+ description: "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what you pass it: the series as a chart, per-network lines where you supply them, and your own read of what changed. Pass `tooShort` when the series has too few points to call a direction, and `edgeIsRecordStart` when the left edge is where the record begins rather than where the conversation did. Call this after you have decided what the numbers mean.",
241
+ inputSchema: z.object({ points: z.array(z.object({}).passthrough()).min(1).max(400), term: z.string().optional(), metric: z.string().optional(), verdict: z.string().optional(), tooShort: z.boolean().optional(), edgeIsRecordStart: z.boolean().optional() }).strict(),
242
+ },
237
243
  {
238
244
  name: "stop_brand_watch",
239
245
  title: "Stop Brand Watch",
@@ -11,10 +11,11 @@ import { NooticrError } from "./nooticr.js";
11
11
  import { NOOTICR_UI_TEMPLATE } from "./ui-template.js";
12
12
  import { registerPrompts } from "./prompts.js";
13
13
  import { OUTPUT_SCHEMAS, anyObject } from "./output-schemas.js";
14
+ import { deriveHashtags, derivedNote } from "./hashtags.js";
14
15
  import { createTaskStore, registerSlowTool } from "./tasks.js";
15
16
  import { COMMENT_CATEGORIES, COMMENT_SENTIMENTS, platformFromUrl, reviewGuidance, toEvidence, } from "./comment-review.js";
16
17
  import { costSentence, EVIDENCE_PLANS, fetchBillingNote, frameIndex, framesToBlocks, planCalls, scoreDraftGuidance, } from "./evidence.js";
17
- import { confirmSpend, declinedResult, searchMentionsCost, SEARCH_PLATFORMS, } from "./spend.js";
18
+ import { confirmSpend, declinedResult, searchMentionsCost, SEARCH_PLATFORMS, DISCOVERABLE_PLATFORMS, } from "./spend.js";
18
19
  import { BackendWatchStore, MemoryWatchStore, registerWatchlist, } from "./watchlist.js";
19
20
  import { registerJobTools } from "./jobs.js";
20
21
  import { registerBrandWatch } from "./brand-watch.js";
@@ -24,7 +25,7 @@ import { loadingPlansJson } from "./loading-plans.js";
24
25
  import { registerHandoff } from "./handoff.js";
25
26
  import { registerCollabTools } from "./collab.js";
26
27
  /** Current MCP server version — bumped on every deploy for traceability. */
27
- export const MCP_SERVER_VERSION = "1.26.25";
28
+ export const MCP_SERVER_VERSION = "1.26.27";
28
29
  /** MCP Apps extension identifier */
29
30
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
30
31
  /** MIME type for MCP Apps HTML resources */
@@ -183,12 +184,6 @@ const RAW_URL_KEYS = new Set([
183
184
  "videoFallbackUrl",
184
185
  "thumbnailFallbackUrl",
185
186
  "musicFallbackUrl",
186
- // Not a media asset — a Stripe Checkout link buy_nooticr_credits returns.
187
- // Without this it fell through to the generic string branch below (which
188
- // proxies *any* https:// string regardless of key name) and got rewritten
189
- // into a /media/proxy?url=... link, a URL meant to serve image/video
190
- // bytes, not redirect to a payment page.
191
- "checkoutUrl",
192
187
  ]);
193
188
  /**
194
189
  * Whether a URL already points at one of our own media endpoints.
@@ -256,6 +251,32 @@ export function proxyUrls(obj) {
256
251
  }
257
252
  return obj;
258
253
  }
254
+ const structuredOf = (res) => (res.structured ?? {});
255
+ /** A structured result this server computed rather than proxied. */
256
+ function toStructured(value) {
257
+ return {
258
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
259
+ structuredContent: value,
260
+ };
261
+ }
262
+ /**
263
+ * A refusal that spent nothing, said as a result rather than an error.
264
+ *
265
+ * An `isError` here would be read by a host as the call having gone wrong, and
266
+ * the model would retry it — the same paid call, refused for the same reason.
267
+ * A plain result carrying the reason is what lets it pick a different argument
268
+ * instead, and `billable: false` is what keeps the backend from charging for a
269
+ * call that answered nothing.
270
+ */
271
+ function unbilled(reason) {
272
+ return toStructured({ available: false, billable: false, reason, mcpCredits: { cost: 0 } });
273
+ }
274
+ /** Tag a proxied result with where its numbers came from. */
275
+ function withSource(result, source, platform) {
276
+ if (!result.structuredContent)
277
+ return result;
278
+ return { ...result, structuredContent: { ...result.structuredContent, source, platform } };
279
+ }
259
280
  async function toToolResult(proxy) {
260
281
  // MCP Apps views (ChatGPT & Claude) render the interactive HTML card, which
261
282
  // already embeds all thumbnails/videos. Claude's Apps bridge rejects a tool
@@ -481,12 +502,14 @@ export function createMcpServer(rawMakeClient, opts) {
481
502
  "niche_report",
482
503
  "find_hook_pattern",
483
504
  "check_nooticr_credits",
484
- "buy_nooticr_credits",
485
505
  "understand_social_post",
486
506
  // The catch-up draws its new posts through the same gallery view; the two
487
507
  // state tools have nothing to show and stay view-less, like nooticr_login.
488
508
  "catch_up_watchlist",
489
509
  "search_mentions",
510
+ // The series a watch keeps, and the free view for the read of it.
511
+ "mention_trend",
512
+ "show_trend",
490
513
  "show_comment_review",
491
514
  // Close the loop the evidence-only tools open: your own analysis/hooks/
492
515
  // variants/repurposing/comparison, drawn — same shape as
@@ -1333,6 +1356,68 @@ export function createMcpServer(rawMakeClient, opts) {
1333
1356
  // ui-template.ts already renders (show_comparison → the comparison
1334
1357
  // scoreboard, show_analysis → analysisCard) or a new one added alongside
1335
1358
  // it (show_hooks, show_variants, show_repurposed_post).
1359
+ server.registerTool("show_trend", {
1360
+ title: "Show Trend",
1361
+ description: "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what " +
1362
+ "you pass it: the series as a chart, per-network lines where you supply them, and your own " +
1363
+ "read of what changed. Pass `tooShort` when the series has too few points to call a " +
1364
+ "direction, and `edgeIsRecordStart` when the left edge is where the record begins rather " +
1365
+ "than where the conversation did — a chart that hides either is the one mistake this view " +
1366
+ "can make on your behalf. Call this after you have decided what the numbers mean.",
1367
+ _meta: {
1368
+ ui: { resourceUri: uiResource("show_trend") },
1369
+ "ui/resourceUri": uiResource("show_trend"),
1370
+ "openai/outputTemplate": appsSdkResource("show_trend"),
1371
+ },
1372
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1373
+ outputSchema: OUTPUT_SCHEMAS.show_trend,
1374
+ inputSchema: z
1375
+ .object({
1376
+ points: z
1377
+ .array(anyObject())
1378
+ .min(1)
1379
+ .max(400)
1380
+ .describe("The series, oldest first or newest first as you like — the same shape mention_trend " +
1381
+ "returned (ranAt, found, reported, perPlatform, medianViews)."),
1382
+ term: z.string().optional().describe("What is being watched."),
1383
+ metric: z
1384
+ .string()
1385
+ .optional()
1386
+ .describe('Which number the chart plots: "found" (default), "reported" or "medianViews".'),
1387
+ verdict: z.string().optional().describe("Your read of what changed, in a sentence or two."),
1388
+ tooShort: z
1389
+ .boolean()
1390
+ .optional()
1391
+ .describe("True when there are too few points to call a direction. Drawn as a stated caveat rather " +
1392
+ "than a trend line, because two points of scraped data are mostly the scrape."),
1393
+ edgeIsRecordStart: z
1394
+ .boolean()
1395
+ .optional()
1396
+ .describe("True when the earliest point is the start of the kept record or the watch's own age, " +
1397
+ "not the start of the conversation. Marks the left edge so a flat start is not read as " +
1398
+ "silence."),
1399
+ })
1400
+ .strict(),
1401
+ }, async (args) => {
1402
+ return {
1403
+ content: [
1404
+ {
1405
+ type: "text",
1406
+ text: `Showing a ${args.points.length}-point trend${args.term ? ` for "${args.term}"` : ""}.` +
1407
+ (args.verdict ? ` ${args.verdict}` : ""),
1408
+ },
1409
+ ],
1410
+ structuredContent: {
1411
+ points: args.points,
1412
+ term: args.term ?? null,
1413
+ metric: args.metric ?? "found",
1414
+ verdict: args.verdict ?? null,
1415
+ tooShort: args.tooShort ?? false,
1416
+ edgeIsRecordStart: args.edgeIsRecordStart ?? false,
1417
+ trend: true,
1418
+ },
1419
+ };
1420
+ });
1336
1421
  server.registerTool("show_comparison", {
1337
1422
  title: "Show Comparison",
1338
1423
  description: "Display a comparison you wrote after compare_posts fetched the first post and you fetched " +
@@ -1567,10 +1652,15 @@ export function createMcpServer(rawMakeClient, opts) {
1567
1652
  });
1568
1653
  server.registerTool("discover_hashtags", {
1569
1654
  title: "Discover Hashtags",
1570
- description: "Trending TikTok hashtags from the Creative Center trend board, with post counts, view counts " +
1571
- "and whether each is rising, cooling or steady. Filter by country and time window. Use to find " +
1572
- "what to tag, or to spot a wave early. Consumes 2 nooticr credits." +
1573
- "Use to find what to tag, or to spot a wave early.",
1655
+ description: "What to tag a post with, on any network that can be searched. TikTok is read from the " +
1656
+ "Creative Center trend board: post counts, view counts, and whether each tag is rising, " +
1657
+ "cooling or steady. The other eight — instagram, youtube, douyin, xiaohongshu, twitter, " +
1658
+ "bilibili, reddit, weibo — have no trend board upstream, so the tags are counted across a " +
1659
+ "live sweep of the niche you name, which needs `niche` set and returns no rising/cooling " +
1660
+ "signal because there is no earlier sample to compare against. The result says which of the " +
1661
+ "two it is in `source`; do not present a counted sample as a trend board. One network cannot " +
1662
+ "be swept at all: linkedin. Filter by country and time window on TikTok. " +
1663
+ "Consumes 2 nooticr credits either way. Use to find what to tag, or to spot a wave early.",
1574
1664
  _meta: {
1575
1665
  ui: { resourceUri: uiResource("discover_hashtags") },
1576
1666
  "ui/resourceUri": uiResource("discover_hashtags"),
@@ -1582,16 +1672,66 @@ export function createMcpServer(rawMakeClient, opts) {
1582
1672
  outputSchema: OUTPUT_SCHEMAS.discover_hashtags,
1583
1673
  inputSchema: z
1584
1674
  .object({
1585
- country: z.string().optional().describe("2-letter country code (default US)."),
1586
- days: z.number().int().optional().describe("Window in days: 7, 30 or 120 (default 7)."),
1675
+ platform: z
1676
+ .string()
1677
+ .optional()
1678
+ .describe("Network to tag for. Defaults to tiktok, the only one with a real trend board. Anything " +
1679
+ "else counts tags across a sweep and needs `niche`. linkedin cannot be swept."),
1680
+ niche: z
1681
+ .string()
1682
+ .optional()
1683
+ .describe("Topic to sweep, required for every platform but tiktok — the tags are counted from the " +
1684
+ "posts a search for this returns, so without it there is nothing to count."),
1685
+ country: z.string().optional().describe("2-letter country code (default US). TikTok only."),
1686
+ days: z.number().int().optional().describe("Window in days: 7, 30 or 120 (default 7). TikTok only."),
1587
1687
  count: z.number().int().optional().describe("Max hashtags (default 20)."),
1588
1688
  industryId: z.string().optional().describe("Optional TikTok industry id to filter by."),
1589
1689
  })
1590
1690
  .strict(),
1591
1691
  }, async (args, extra) => {
1592
1692
  const client = await makeClient({ ...extra, arguments: args });
1693
+ const platform = (args.platform || "tiktok").toLowerCase();
1694
+ // TikTok keeps the trend board it always had — a real rising/cooling signal
1695
+ // is worth more than a count, so nothing routes away from it.
1696
+ if (platform === "tiktok") {
1697
+ try {
1698
+ const { platform: _p, niche: _n, ...board } = args;
1699
+ const result = await toToolResult(await client.callTool("discover_hashtags", board));
1700
+ return withSource(result, "trend-board", platform);
1701
+ }
1702
+ catch (err) {
1703
+ return toolError("discover_hashtags failed", err);
1704
+ }
1705
+ }
1706
+ // Everything else is derived, and two things make that impossible rather
1707
+ // than merely worse. Refuse both before spending a credit: a paid call that
1708
+ // cannot answer is the failure mode the platform-claims tests exist for.
1709
+ if (!DISCOVERABLE_PLATFORMS.includes(platform)) {
1710
+ return unbilled(`${platform} cannot be swept for posts, so there is no sample to count tags in and no trend ` +
1711
+ `board upstream either. Searchable networks: ${DISCOVERABLE_PLATFORMS.join(", ")}. Nothing was charged.`);
1712
+ }
1713
+ if (!args.niche?.trim()) {
1714
+ return unbilled(`Only tiktok has a trend board. Tags for ${platform} are counted across a sweep, so this ` +
1715
+ "needs `niche` set to the topic to sweep. Nothing was charged.");
1716
+ }
1593
1717
  try {
1594
- return await toToolResult(await client.callTool("discover_hashtags", { ...args }));
1718
+ const res = await client.callTool("discover_social_posts", {
1719
+ niche: args.niche,
1720
+ platform,
1721
+ limit: 30,
1722
+ });
1723
+ const structured = structuredOf(res);
1724
+ const posts = Array.isArray(structured.posts) ? structured.posts : [];
1725
+ const hashtags = deriveHashtags(posts, args.count ?? 20);
1726
+ return toStructured({
1727
+ hashtags,
1728
+ platform,
1729
+ niche: args.niche,
1730
+ source: "derived-from-sweep",
1731
+ sweptPosts: posts.length,
1732
+ note: derivedNote(args.niche, platform, posts.length, hashtags.length),
1733
+ mcpCredits: structured.mcpCredits ?? null,
1734
+ });
1595
1735
  }
1596
1736
  catch (err) {
1597
1737
  return toolError("discover_hashtags failed", err);
@@ -1903,8 +2043,11 @@ export function createMcpServer(rawMakeClient, opts) {
1903
2043
  });
1904
2044
  server.registerTool("check_nooticr_credits", {
1905
2045
  title: "Check Nooticr Credits",
1906
- description: "Check your nooticr credit balance, billing URL and pack size. No cost — call anytime to see remaining credits before running other tools." +
1907
- "No cost to call. Use before a run of paid calls to confirm the balance covers it.",
2046
+ description: "Check your nooticr credit balance. No cost to call — call anytime to see remaining credits " +
2047
+ "before running other tools. Nothing here sells or tops up credits: this server offers no purchase " +
2048
+ "of any kind, so when the balance is short, say that it is and that topping up happens on the " +
2049
+ "nooticr website, and do not offer a link or a price. " +
2050
+ "Use before a run of paid calls to confirm the balance covers it.",
1908
2051
  _meta: {
1909
2052
  ui: { resourceUri: uiResource("check_nooticr_credits") },
1910
2053
  "ui/resourceUri": uiResource("check_nooticr_credits"),
@@ -1918,35 +2061,31 @@ export function createMcpServer(rawMakeClient, opts) {
1918
2061
  }, async (_args, extra) => {
1919
2062
  const client = await makeClient(extra);
1920
2063
  try {
1921
- return await toToolResult(await client.callTool("check_nooticr_credits", {}));
2064
+ const proxy = await client.callTool("check_nooticr_credits", {});
2065
+ // The backend still returns a billing URL, because the dashboard and the
2066
+ // website both use it. It is dropped here rather than passed through: a
2067
+ // link to buy credits is a link to buy a digital good, and this server
2068
+ // must offer no purchase path at all — not a checkout, and not a pointer
2069
+ // to one. Stripped at the edge, so the backend needs no change and no
2070
+ // other consumer of it does either.
2071
+ //
2072
+ // Dropped from the proxy result BEFORE toToolResult, not from what it
2073
+ // returns. toToolResult also serialises the payload into the text block,
2074
+ // so stripping the structured half afterwards left the URL in the text a
2075
+ // model reads — which the guard in tests/site.test.ts caught on its first
2076
+ // run, and is the whole reason it asserts on the serialised result rather
2077
+ // than on `structuredContent` alone.
2078
+ const structured = proxy.structured;
2079
+ if (structured && "billingUrl" in structured) {
2080
+ const { billingUrl: _dropped, ...rest } = structured;
2081
+ return await toToolResult({ ...proxy, structured: rest });
2082
+ }
2083
+ return await toToolResult(proxy);
1922
2084
  }
1923
2085
  catch (err) {
1924
2086
  return toolError("check_nooticr_credits failed", err);
1925
2087
  }
1926
2088
  });
1927
- server.registerTool("buy_nooticr_credits", {
1928
- title: "Buy Nooticr Credits",
1929
- description: "Buy an MCP credit pack via Stripe Checkout. Returns a secure checkout URL — open it in your browser to pay. Credits are added automatically after payment. No cost to call." +
1930
- "Use when the balance is short and the user has agreed to top up.",
1931
- _meta: {
1932
- ui: { resourceUri: uiResource("buy_nooticr_credits") },
1933
- "ui/resourceUri": uiResource("buy_nooticr_credits"),
1934
- // ChatGPT reads only this one, and reads it to find the
1935
- // text/html+skybridge twin rather than the Claude resource.
1936
- "openai/outputTemplate": appsSdkResource("buy_nooticr_credits"),
1937
- },
1938
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1939
- outputSchema: OUTPUT_SCHEMAS.buy_nooticr_credits,
1940
- inputSchema: z.object({}).strict(),
1941
- }, async (_args, extra) => {
1942
- const client = await makeClient(extra);
1943
- try {
1944
- return await toToolResult(await client.callTool("buy_nooticr_credits", {}));
1945
- }
1946
- catch (err) {
1947
- return toolError("buy_nooticr_credits failed", err);
1948
- }
1949
- });
1950
2089
  server.registerTool("nooticr_login", {
1951
2090
  title: "Nooticr Login",
1952
2091
  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." +
@@ -42,7 +42,6 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
42
42
  discover_sounds:"Discover Sounds",
43
43
  understand_social_post:"Understand Social Post",
44
44
  check_nooticr_credits:"Check Credits",
45
- buy_nooticr_credits:"Buy Credits",
46
45
  compose_sequence:"Compose Sequence",
47
46
  overlay_bake:"Overlay Bake",
48
47
  spawn_variants:"Spawn Variants",
@@ -66,7 +65,6 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
66
65
  discover_sounds:"Trending sounds and music on TikTok/Instagram.",
67
66
  understand_social_post:"The same frames and transcript, for a description of what happens on screen.",
68
67
  check_nooticr_credits:"View your Nooticr credit balance and usage.",
69
- buy_nooticr_credits:"Purchase additional Nooticr credits.",
70
68
  compose_sequence:"AI-powered content composition for social posts.",
71
69
  overlay_bake:"Bake text/image overlays onto video or image.",
72
70
  spawn_variants:"Generate multiple content variants from a single seed.",
@@ -3691,20 +3689,85 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
3691
3689
  +"</div></div>";
3692
3690
  return;}
3693
3691
 
3694
- // Trending hashtags
3692
+ // A run series. Drawn as bars rather than a line: the points are sweeps on
3693
+ // a cadence, not a continuous signal, and a line between two sweeps
3694
+ // asserts the shape of something nobody measured. Both caveats the tool
3695
+ // computes are drawn — a chart that hides "too few points" or "this is
3696
+ // where the record starts, not where the conversation did" is the view
3697
+ // making a claim the tool refused to (issue #28).
3698
+ if(Array.isArray(d.points)&&(d.trend||d.tool==="mention_trend")){
3699
+ var metric=String(d.metric||"found");
3700
+ var pts=d.points.slice().sort(function(a,b){return String(a.ranAt||"")<String(b.ranAt||"")?-1:1;});
3701
+ var vals=pts.map(function(p){var v=p[metric];return typeof v==="number"?v:0;});
3702
+ var top=Math.max.apply(null,vals.concat([1]));
3703
+ var bars=pts.map(function(p,i){
3704
+ var v=vals[i];
3705
+ var h=Math.max(2,Math.round((v/top)*90));
3706
+ var when=String(p.ranAt||"").slice(0,10);
3707
+ var label=when+" — "+fmtNum(v)+" "+metric+(p.reported!=null?" ("+fmtNum(p.reported)+" new)":"");
3708
+ return '<div style="flex:1;min-width:3px;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:3px" title="'+esc(label)+'">'
3709
+ +'<div style="width:100%;max-width:26px;height:'+h+'px;background:var(--brand);border-radius:3px 3px 0 0;opacity:'+(i===pts.length-1?"1":".62")+'"></div>'
3710
+ +"</div>";
3711
+ }).join("");
3712
+ var first=pts.length?String(pts[0].ranAt||"").slice(0,10):"";
3713
+ var last=pts.length?String(pts[pts.length-1].ranAt||"").slice(0,10):"";
3714
+ app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
3715
+ +'<div style="font-size:16px;font-weight:700">'+(d.term?esc(String(d.term)):"Trend")+"</div>"
3716
+ +'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:2px 0 12px">'
3717
+ +pts.length+" run"+(pts.length===1?"":"s")+" · "+esc(metric)+" per sweep</div>"
3718
+ +(d.tooShort
3719
+ ?'<div style="font-size:12.5px;color:var(--warn);font-weight:600;margin-bottom:8px">Too few points to call a direction</div>'
3720
+ :"")
3721
+ +'<div style="display:flex;align-items:flex-end;gap:2px;height:100px;border-bottom:1px solid var(--border)">'+bars+"</div>"
3722
+ +'<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:5px">'
3723
+ +"<span>"+esc(first)+(d.edgeIsRecordStart?" · record starts here":"")+"</span><span>"+esc(last)+"</span></div>"
3724
+ +(d.verdict?'<div style="font-size:13.5px;line-height:1.6;margin-top:12px">'+esc(String(d.verdict))+"</div>":"")
3725
+ +'<div class="faint" style="font-size:11.5px;color:var(--muted);margin-top:10px;line-height:1.6">'
3726
+ +"Each bar is one sweep, not a day"
3727
+ +(d.edgeIsRecordStart?" — and the earliest bar is where the kept record begins, not where the conversation did":"")
3728
+ +". A flat run of bars is a conversation that has stopped moving, which is not the same as one that has stopped."
3729
+ +"</div></div></div>";
3730
+ return;}
3731
+
3732
+ // Trending hashtags. Two sources with different evidence behind them: the
3733
+ // TikTok trend board, which measures direction over time, and a count
3734
+ // across one niche sweep, which cannot. The header names which, and a
3735
+ // counted row draws no trend arrow — an arrow for a direction nobody
3736
+ // measured is the view asserting something the tool did not.
3695
3737
  if(d.hashtags&&Array.isArray(d.hashtags)){
3696
- if(!d.hashtags.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">#</div><div class="text">No trending hashtags found</div></div>';return;}
3738
+ var derived=d.source==="derived-from-sweep";
3739
+ var where=derived
3740
+ ?esc(String(d.platform||"")||"this network")+" · counted across "+fmtNum(d.sweptPosts||0)+" post"+((d.sweptPosts===1)?"":"s")+(d.niche?' matching "'+esc(d.niche)+'"':"")
3741
+ :"TikTok trend board · "+esc(d.country||"US")+" · last "+(d.days||7)+" days";
3742
+ if(!d.hashtags.length){
3743
+ app.innerHTML='<div class="empty-state fade-in"><div class="icon">#</div><div class="text">'
3744
+ +esc(d.reason||(derived?"No tag was used by more than one post in that sweep":"No trending hashtags found"))
3745
+ +'</div><div class="sub" style="font-size:11.5px;color:var(--muted);margin-top:6px">'+where+"</div></div>";
3746
+ return;}
3697
3747
  var hr=d.hashtags.slice(0,30).map(function(t){
3698
3748
  var dir=t.trend==="rising"?["▲","var(--green)"]:t.trend==="cooling"?["▼","var(--red)"]:["▬","var(--muted)"];
3699
- return '<a href="'+esc(t.url||"#")+'" target="_blank" rel="noopener" class="comment-row" style="text-decoration:none">'
3749
+ var stat=fmtNum(t.posts||0)+" post"+((t.posts===1)?"":"s");
3750
+ // A counted sample reports the middle post, because one outlier in a
3751
+ // 30-post sweep moves a total and not a median.
3752
+ if(derived){if(t.medianViews!=null)stat+=" · median "+fmtNum(t.medianViews)+" views";}
3753
+ else if(t.views!=null)stat+=" · "+fmtNum(t.views)+" views";
3754
+ var href=t.url||t.example||"";
3755
+ var open=href?'<a href="'+esc(href)+'" target="_blank" rel="noopener" class="comment-row" style="text-decoration:none">':'<div class="comment-row">';
3756
+ return open
3700
3757
  +'<div style="flex:1;min-width:0"><div style="font-size:13.5px;font-weight:600">#'+esc(t.hashtag)+"</div>"
3701
- +'<div style="font-size:11.5px;color:var(--muted)">'+fmtNum(t.posts||0)+" posts · "+fmtNum(t.views||0)+" views</div></div>"
3702
- +'<span style="font-size:11px;font-weight:700;color:'+dir[1]+';white-space:nowrap">'+dir[0]+" "+esc(t.trend||"")+"</span></a>";
3758
+ +'<div style="font-size:11.5px;color:var(--muted)">'+stat+"</div></div>"
3759
+ +(t.trend?'<span style="font-size:11px;font-weight:700;color:'+dir[1]+';white-space:nowrap">'+dir[0]+" "+esc(t.trend)+"</span>":"")
3760
+ +(href?"</a>":"</div>");
3703
3761
  }).join("");
3704
3762
  app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
3705
- +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700">#<span>Trending hashtags</span></div>'
3706
- +'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:2px 0 10px">TikTok · '+esc(d.country||"US")+" · last "+(d.days||7)+" days</div>"
3707
- +hr+"</div></div>";
3763
+ +'<div style="display:flex;align-items:center;gap:7px;font-size:16px;font-weight:700">#<span>'+(derived?"Tags in this niche":"Trending hashtags")+"</span></div>"
3764
+ +'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:2px 0 10px">'+where+"</div>"
3765
+ +hr
3766
+ // The caveat goes in front of the person too, not only into the
3767
+ // context handed to the model: a ranked list reads as a trend whatever
3768
+ // the payload says, so the sentence saying it is not sits under it.
3769
+ +(derived&&d.note?'<div class="faint" style="font-size:11.5px;color:var(--muted);margin-top:12px;line-height:1.6">'+esc(d.note)+"</div>":"")
3770
+ +"</div></div>";
3708
3771
  return;}
3709
3772
 
3710
3773
  // Comment analysis
@@ -3815,32 +3878,6 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
3815
3878
  +(tier?'<span class="tier-badge">'+esc(tier)+"</span>":"")+"</div>"
3816
3879
  +'<div style="font-size:36px;font-weight:800;margin:10px 0;letter-spacing:-1px">'+bal+"</div>"
3817
3880
  +'<div style="font-size:13px;color:var(--muted)">credits remaining</div>'+freeHtml+"</div>";return;}
3818
- // Checkout. Real packs/prices, not the three hardcoded tiers this used
3819
- // to show regardless of what the backend actually returned — and each
3820
- // card is a real <a href> now, so the existing generic anchor handler
3821
- // (search this file for "cannot navigate the top-level window") opens
3822
- // the real checkoutUrl via ui/open-link. There was no click handler at
3823
- // all before this: .pack{cursor:pointer} in the CSS promised a click
3824
- // that did nothing.
3825
- if(d.checkoutUrl||d.packs){
3826
- var checkoutUrl=d.checkoutUrl||"#";
3827
- var packList=Array.isArray(d.packs)?d.packs:[];
3828
- var packHtml=function(p,featured){
3829
- var name=(p&&(p.name||p.label||p.title))||"Credits";
3830
- var price=(p&&(p.price||p.priceLabel||p.cost))||"";
3831
- var credits=p&&p.credits!=null?fmtNum(p.credits)+" credits":(p&&(p.description||p.credit))||"";
3832
- return '<a href="'+esc(checkoutUrl)+'" target="_blank" rel="noopener" class="'+(featured?"pack-featured":"pack")+'" style="text-decoration:none;color:inherit;display:block">'
3833
- +'<div class="text-sm font-semibold">'+esc(name)+'</div>'
3834
- +(price?'<div class="mt-2 text-2xl font-extrabold">'+esc(price)+"</div>":"")
3835
- +(credits?'<div class="mt-2 text-xs text-muted">'+esc(credits)+"</div>":"")+"</a>";
3836
- };
3837
- var cards=packList.length
3838
- ? packList.map(function(p,i){return packHtml(p,i===1);}).join("")
3839
- : packHtml({name:"Buy credits",description:"Opens Stripe Checkout"},true);
3840
- app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
3841
- +'<div class="mb-3 text-base font-bold">🛒 Credit Packs</div>'
3842
- +'<div class="grid grid-cols-3 gap-2">'+cards
3843
- +"</div></div></div>";return;}
3844
3881
  // Hooks (show_hooks) — quote() gives each one its own Copy button.
3845
3882
  if(d.hooks&&Array.isArray(d.hooks)){
3846
3883
  if(!d.hooks.length){app.innerHTML='<div class="empty-state fade-in"><div class="icon">💬</div><div class="text">No hooks yet</div></div>';return;}
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@nooticr/mcp",
3
3
  "mcpName": "com.nooticr/mcp",
4
- "version": "1.26.25",
4
+ "version": "1.26.27",
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
+ "homepage": "https://mcp.nooticr.com",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Nooticr/nooticr-mcp.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Nooticr/nooticr-mcp/issues"
13
+ },
14
+ "author": "Nooticr <support@nooticr.com>",
6
15
  "type": "module",
7
16
  "main": "dist/index.js",
8
17
  "files": [
@@ -35,7 +44,8 @@
35
44
  "contract:manifest": "node scripts/contract-check.mjs --from=../nooticr-server/platform-capabilities.json",
36
45
  "test:mutation": "node scripts/mutation-guard.mjs",
37
46
  "test:invariants": "node scripts/invariant-guard.mjs",
38
- "verify:deep": "npm run verify && npm run test:mutation && npm run test:invariants"
47
+ "verify:deep": "npm run verify && npm run test:mutation && npm run test:invariants",
48
+ "check:submission": "node scripts/check-submission-schema.mjs"
39
49
  },
40
50
  "keywords": [
41
51
  "mcp",