@nooticr/mcp 1.26.25 → 1.26.26

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. |
@@ -136,7 +136,6 @@ them caps that fan-out with an argument.
136
136
  | Tool | Credits | What it is for |
137
137
  |------|---------|----------------|
138
138
  | `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
139
  | `nooticr_login` | free | Re-link the account when a call fails with an authentication error. |
141
140
  | `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
141
  | `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 +484,7 @@ and text** posts.
485
484
  lookup or transcript, 2 for discovery and for a tool that makes one fetch, 3
486
485
  for the two that fetch frames *and* transcript. Every tool bills from the
487
486
  first call. Top up via
488
- `buy_nooticr_credits`, `check_nooticr_credits`, or the nooticr dashboard at
487
+ `check_nooticr_credits`, or the nooticr dashboard at
489
488
  `https://nooticr.com/settings?tab=billing`.
490
489
  - **Expired refresh token**: the stored refresh token was rejected by the
491
490
  nooticr server. Run `npx @nooticr/mcp login` again to re-authenticate.
@@ -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
+ }
@@ -343,7 +343,6 @@ export const LOADING_PLANS = {
343
343
  generate_captions: { label: "Writing the captions", kind: "text", n: 1, steps: [], planAi: true },
344
344
  // ─── Account and own-account: free, and free at a zero balance ───
345
345
  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
346
  nooticr_login: { label: "Getting a sign-in link", kind: "text", n: 1, steps: [], free: true },
348
347
  watch_creator: { label: "Adding to your watchlist", kind: "text", n: 1, steps: [], free: true },
349
348
  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."),
@@ -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
  {
@@ -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.26";
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,7 +502,6 @@ 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.
@@ -1567,10 +1587,15 @@ export function createMcpServer(rawMakeClient, opts) {
1567
1587
  });
1568
1588
  server.registerTool("discover_hashtags", {
1569
1589
  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.",
1590
+ description: "What to tag a post with, on any network that can be searched. TikTok is read from the " +
1591
+ "Creative Center trend board: post counts, view counts, and whether each tag is rising, " +
1592
+ "cooling or steady. The other eight — instagram, youtube, douyin, xiaohongshu, twitter, " +
1593
+ "bilibili, reddit, weibo — have no trend board upstream, so the tags are counted across a " +
1594
+ "live sweep of the niche you name, which needs `niche` set and returns no rising/cooling " +
1595
+ "signal because there is no earlier sample to compare against. The result says which of the " +
1596
+ "two it is in `source`; do not present a counted sample as a trend board. One network cannot " +
1597
+ "be swept at all: linkedin. Filter by country and time window on TikTok. " +
1598
+ "Consumes 2 nooticr credits either way. Use to find what to tag, or to spot a wave early.",
1574
1599
  _meta: {
1575
1600
  ui: { resourceUri: uiResource("discover_hashtags") },
1576
1601
  "ui/resourceUri": uiResource("discover_hashtags"),
@@ -1582,16 +1607,66 @@ export function createMcpServer(rawMakeClient, opts) {
1582
1607
  outputSchema: OUTPUT_SCHEMAS.discover_hashtags,
1583
1608
  inputSchema: z
1584
1609
  .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)."),
1610
+ platform: z
1611
+ .string()
1612
+ .optional()
1613
+ .describe("Network to tag for. Defaults to tiktok, the only one with a real trend board. Anything " +
1614
+ "else counts tags across a sweep and needs `niche`. linkedin cannot be swept."),
1615
+ niche: z
1616
+ .string()
1617
+ .optional()
1618
+ .describe("Topic to sweep, required for every platform but tiktok — the tags are counted from the " +
1619
+ "posts a search for this returns, so without it there is nothing to count."),
1620
+ country: z.string().optional().describe("2-letter country code (default US). TikTok only."),
1621
+ days: z.number().int().optional().describe("Window in days: 7, 30 or 120 (default 7). TikTok only."),
1587
1622
  count: z.number().int().optional().describe("Max hashtags (default 20)."),
1588
1623
  industryId: z.string().optional().describe("Optional TikTok industry id to filter by."),
1589
1624
  })
1590
1625
  .strict(),
1591
1626
  }, async (args, extra) => {
1592
1627
  const client = await makeClient({ ...extra, arguments: args });
1628
+ const platform = (args.platform || "tiktok").toLowerCase();
1629
+ // TikTok keeps the trend board it always had — a real rising/cooling signal
1630
+ // is worth more than a count, so nothing routes away from it.
1631
+ if (platform === "tiktok") {
1632
+ try {
1633
+ const { platform: _p, niche: _n, ...board } = args;
1634
+ const result = await toToolResult(await client.callTool("discover_hashtags", board));
1635
+ return withSource(result, "trend-board", platform);
1636
+ }
1637
+ catch (err) {
1638
+ return toolError("discover_hashtags failed", err);
1639
+ }
1640
+ }
1641
+ // Everything else is derived, and two things make that impossible rather
1642
+ // than merely worse. Refuse both before spending a credit: a paid call that
1643
+ // cannot answer is the failure mode the platform-claims tests exist for.
1644
+ if (!DISCOVERABLE_PLATFORMS.includes(platform)) {
1645
+ return unbilled(`${platform} cannot be swept for posts, so there is no sample to count tags in and no trend ` +
1646
+ `board upstream either. Searchable networks: ${DISCOVERABLE_PLATFORMS.join(", ")}. Nothing was charged.`);
1647
+ }
1648
+ if (!args.niche?.trim()) {
1649
+ return unbilled(`Only tiktok has a trend board. Tags for ${platform} are counted across a sweep, so this ` +
1650
+ "needs `niche` set to the topic to sweep. Nothing was charged.");
1651
+ }
1593
1652
  try {
1594
- return await toToolResult(await client.callTool("discover_hashtags", { ...args }));
1653
+ const res = await client.callTool("discover_social_posts", {
1654
+ niche: args.niche,
1655
+ platform,
1656
+ limit: 30,
1657
+ });
1658
+ const structured = structuredOf(res);
1659
+ const posts = Array.isArray(structured.posts) ? structured.posts : [];
1660
+ const hashtags = deriveHashtags(posts, args.count ?? 20);
1661
+ return toStructured({
1662
+ hashtags,
1663
+ platform,
1664
+ niche: args.niche,
1665
+ source: "derived-from-sweep",
1666
+ sweptPosts: posts.length,
1667
+ note: derivedNote(args.niche, platform, posts.length, hashtags.length),
1668
+ mcpCredits: structured.mcpCredits ?? null,
1669
+ });
1595
1670
  }
1596
1671
  catch (err) {
1597
1672
  return toolError("discover_hashtags failed", err);
@@ -1903,8 +1978,11 @@ export function createMcpServer(rawMakeClient, opts) {
1903
1978
  });
1904
1979
  server.registerTool("check_nooticr_credits", {
1905
1980
  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.",
1981
+ description: "Check your nooticr credit balance. No cost to call — call anytime to see remaining credits " +
1982
+ "before running other tools. Nothing here sells or tops up credits: this server offers no purchase " +
1983
+ "of any kind, so when the balance is short, say that it is and that topping up happens on the " +
1984
+ "nooticr website, and do not offer a link or a price. " +
1985
+ "Use before a run of paid calls to confirm the balance covers it.",
1908
1986
  _meta: {
1909
1987
  ui: { resourceUri: uiResource("check_nooticr_credits") },
1910
1988
  "ui/resourceUri": uiResource("check_nooticr_credits"),
@@ -1918,35 +1996,31 @@ export function createMcpServer(rawMakeClient, opts) {
1918
1996
  }, async (_args, extra) => {
1919
1997
  const client = await makeClient(extra);
1920
1998
  try {
1921
- return await toToolResult(await client.callTool("check_nooticr_credits", {}));
1999
+ const proxy = await client.callTool("check_nooticr_credits", {});
2000
+ // The backend still returns a billing URL, because the dashboard and the
2001
+ // website both use it. It is dropped here rather than passed through: a
2002
+ // link to buy credits is a link to buy a digital good, and this server
2003
+ // must offer no purchase path at all — not a checkout, and not a pointer
2004
+ // to one. Stripped at the edge, so the backend needs no change and no
2005
+ // other consumer of it does either.
2006
+ //
2007
+ // Dropped from the proxy result BEFORE toToolResult, not from what it
2008
+ // returns. toToolResult also serialises the payload into the text block,
2009
+ // so stripping the structured half afterwards left the URL in the text a
2010
+ // model reads — which the guard in tests/site.test.ts caught on its first
2011
+ // run, and is the whole reason it asserts on the serialised result rather
2012
+ // than on `structuredContent` alone.
2013
+ const structured = proxy.structured;
2014
+ if (structured && "billingUrl" in structured) {
2015
+ const { billingUrl: _dropped, ...rest } = structured;
2016
+ return await toToolResult({ ...proxy, structured: rest });
2017
+ }
2018
+ return await toToolResult(proxy);
1922
2019
  }
1923
2020
  catch (err) {
1924
2021
  return toolError("check_nooticr_credits failed", err);
1925
2022
  }
1926
2023
  });
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
2024
  server.registerTool("nooticr_login", {
1951
2025
  title: "Nooticr Login",
1952
2026
  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,45 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
3691
3689
  +"</div></div>";
3692
3690
  return;}
3693
3691
 
3694
- // Trending hashtags
3692
+ // Trending hashtags. Two sources with different evidence behind them: the
3693
+ // TikTok trend board, which measures direction over time, and a count
3694
+ // across one niche sweep, which cannot. The header names which, and a
3695
+ // counted row draws no trend arrow — an arrow for a direction nobody
3696
+ // measured is the view asserting something the tool did not.
3695
3697
  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;}
3698
+ var derived=d.source==="derived-from-sweep";
3699
+ var where=derived
3700
+ ?esc(String(d.platform||"")||"this network")+" · counted across "+fmtNum(d.sweptPosts||0)+" post"+((d.sweptPosts===1)?"":"s")+(d.niche?' matching "'+esc(d.niche)+'"':"")
3701
+ :"TikTok trend board · "+esc(d.country||"US")+" · last "+(d.days||7)+" days";
3702
+ if(!d.hashtags.length){
3703
+ app.innerHTML='<div class="empty-state fade-in"><div class="icon">#</div><div class="text">'
3704
+ +esc(d.reason||(derived?"No tag was used by more than one post in that sweep":"No trending hashtags found"))
3705
+ +'</div><div class="sub" style="font-size:11.5px;color:var(--muted);margin-top:6px">'+where+"</div></div>";
3706
+ return;}
3697
3707
  var hr=d.hashtags.slice(0,30).map(function(t){
3698
3708
  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">'
3709
+ var stat=fmtNum(t.posts||0)+" post"+((t.posts===1)?"":"s");
3710
+ // A counted sample reports the middle post, because one outlier in a
3711
+ // 30-post sweep moves a total and not a median.
3712
+ if(derived){if(t.medianViews!=null)stat+=" · median "+fmtNum(t.medianViews)+" views";}
3713
+ else if(t.views!=null)stat+=" · "+fmtNum(t.views)+" views";
3714
+ var href=t.url||t.example||"";
3715
+ var open=href?'<a href="'+esc(href)+'" target="_blank" rel="noopener" class="comment-row" style="text-decoration:none">':'<div class="comment-row">';
3716
+ return open
3700
3717
  +'<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>";
3718
+ +'<div style="font-size:11.5px;color:var(--muted)">'+stat+"</div></div>"
3719
+ +(t.trend?'<span style="font-size:11px;font-weight:700;color:'+dir[1]+';white-space:nowrap">'+dir[0]+" "+esc(t.trend)+"</span>":"")
3720
+ +(href?"</a>":"</div>");
3703
3721
  }).join("");
3704
3722
  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>";
3723
+ +'<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>"
3724
+ +'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:2px 0 10px">'+where+"</div>"
3725
+ +hr
3726
+ // The caveat goes in front of the person too, not only into the
3727
+ // context handed to the model: a ranked list reads as a trend whatever
3728
+ // the payload says, so the sentence saying it is not sits under it.
3729
+ +(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>":"")
3730
+ +"</div></div>";
3708
3731
  return;}
3709
3732
 
3710
3733
  // Comment analysis
@@ -3815,32 +3838,6 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
3815
3838
  +(tier?'<span class="tier-badge">'+esc(tier)+"</span>":"")+"</div>"
3816
3839
  +'<div style="font-size:36px;font-weight:800;margin:10px 0;letter-spacing:-1px">'+bal+"</div>"
3817
3840
  +'<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
3841
  // Hooks (show_hooks) — quote() gives each one its own Copy button.
3845
3842
  if(d.hooks&&Array.isArray(d.hooks)){
3846
3843
  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.26",
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",