@nooticr/mcp 1.26.21 → 1.26.23

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
@@ -14,8 +14,8 @@ model of ours for an opinion first. You pay for the fetch and nothing else.
14
14
 
15
15
  It also **monitors a name**: `search_mentions` sweeps nine of those networks for
16
16
  every comment that says your brand, inside a date window you choose, and
17
- `search_spoken_mentions` reads the words actually said out loud in TikTok and
18
- YouTube videos for the mentions that were never typed anywhere.
17
+ `search_spoken_mentions` reads the words actually said out loud in TikTok,
18
+ YouTube and Douyin videos for the mentions that were never typed anywhere.
19
19
 
20
20
  Runs over stdio locally or as a hosted connector at `https://mcp.nooticr.com/mcp`.
21
21
  Billed against your nooticr credits; new accounts get 20 free.
@@ -52,7 +52,7 @@ npx @nooticr/mcp login # one-time sign-in (Google)
52
52
 
53
53
  ## Tools
54
54
 
55
- 49 tools, grouped by what you are trying to do. Prices are in nooticr credits and
55
+ 64 tools, grouped by what you are trying to do. Prices are in nooticr credits and
56
56
  match what the server actually charges.
57
57
 
58
58
  Seven of them — the ones under **Answer a question you actually have** — are not
@@ -67,7 +67,7 @@ them caps that fan-out with an argument.
67
67
  | Tool | Credits | What it is for |
68
68
  |------|---------|----------------|
69
69
  | `get_social_media` | 1 | The post's facts and media — contentType, title, caption, author, stats, direct media URLs, plus an inline thumbnail. Use when you want the post itself and nothing interpreted. |
70
- | `get_post_transcript` | 1 | The words actually spoken, read from the post's caption track (TikTok and YouTube). Exact rather than inferred, and far cheaper than watching the video. Use before any analysis when the wording matters. |
70
+ | `get_post_transcript` | 1 | The words actually spoken. Where the platform publishes a caption track (TikTok, Douyin, YouTube) it is read as-is — exact rather than inferred, and far cheaper than watching the video; everywhere else the post's own audio is transcribed, which needs speech-to-text configured on the server. Use before any analysis when the wording matters. |
71
71
  | `get_post_frames` | 2 | Frames sampled evenly across a post's video, returned as **images you can actually look at** — not a description of them. ffmpeg opens the stream directly rather than downloading it, so HLS works and an expired link is re-resolved on the spot. Verified live at 3/3 on TikTok, YouTube, Instagram, Douyin and X; Reddit works on video posts. A carousel or slideshow returns its own images unchanged. Each frame costs roughly 1,200 tokens of your context. |
72
72
  | `get_post_comments` | 2 | Top comments plus the themes the platform clusters them into, with which ones the creator pinned or liked. Use when you want to read what people wrote. |
73
73
 
@@ -95,7 +95,7 @@ them caps that fan-out with an argument.
95
95
  | `discover_sounds` | 2 | Trending audio with playable previews. Sound is a major ranking signal on TikTok. |
96
96
  | `discover_hashtags` | 2 | Trending hashtags with volumes and whether each is rising, cooling or steady. |
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
- | `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 and YouTube. |
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. |
100
100
  | `unwatch_creator` | free | Drop a creator from the watchlist. |
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". |
@@ -117,7 +117,7 @@ them caps that fan-out with an argument.
117
117
  | `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. |
118
118
  | `why_did_this_underperform` | 3 | One post against the creator's own recent distribution, with the post taken back out of its own baseline. Returns median, quartiles, ratio and percentile, so the answer can be "this is an ordinary result, not a failure". Different question from `compare_posts`, which weighs two URLs you already picked. |
119
119
  | `what_should_i_make_next` | 2 + 2 per post read + 2 (12 by default) | Demand against supply: what your commenters explicitly ask for, set beside what a niche sweep shows is already being made. A gap nobody asked for is noise; a request nobody serves is the opportunity. Falls back to your most-used hashtag when you name no niche. |
120
- | `search_spoken_mentions` | 2 per platform narrowed by niche + 2 per creator handle + 1 per transcript, up to `maxTranscripts` | **The mirror of `search_mentions`, for what was said rather than typed.** Narrows to candidate posts (a niche sweep, named handles, and/or your watchlist), transcribes only the most-viewed survivors up to a hard ceiling, and searches the words for the term — TikTok and YouTube only, and only where the platform actually supplies a caption track. Reports how many candidates were found, transcribed and matched, so the spend is legible. |
120
+ | `search_spoken_mentions` | 2 per platform narrowed by niche + 2 per creator handle + 1 per transcript, up to `maxTranscripts` | **The mirror of `search_mentions`, for what was said rather than typed.** Narrows to candidate posts (a niche sweep, named handles, and/or your watchlist), transcribes only the most-viewed survivors up to a hard ceiling, and searches the words for the term — TikTok, YouTube and Douyin, and only where the creator actually enabled captions. Reports how many candidates were found, transcribed and matched, so the spend is legible. |
121
121
 
122
122
  ### Make something
123
123
 
@@ -138,6 +138,10 @@ them caps that fan-out with an argument.
138
138
  | `check_nooticr_credits` | free | Balance and billing URL. |
139
139
  | `buy_nooticr_credits` | free | A Stripe Checkout URL for a credit pack. Credits land automatically after payment. |
140
140
  | `nooticr_login` | free | Re-link the account when a call fails with an authentication error. |
141
+ | `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
+ | `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. |
143
+ | `create_product` | free | Create a product in your own workspace — the row every other own-account tool needs before it has anything to work with. Always creates in the calling session's workspace, never one you could name. `name` and `slug` are required; every other field is snake_case and read by exact key, so a camelCase `websiteUrl` lands nowhere. Writes no brand playbook. |
144
+ | `update_product` | free | Patch a product's fields. An omitted argument leaves its column unchanged, so this cannot blank a field by not mentioning it, and the result lists what was actually written — a misspelled field shows up as one that did not change rather than as a silent no-op. |
141
145
 
142
146
  ### Your own product
143
147
 
@@ -250,17 +254,31 @@ selectable. Free, and it makes no requests.
250
254
  ## Before an expensive call, it asks
251
255
 
252
256
  Most tools print their price in their own description, so a call costs what you
253
- already read. Two do not, because their price is set by an argument:
257
+ already read. Six do not, because their price is set by an argument — or by a
258
+ list the request never mentions:
254
259
 
255
260
  - `search_mentions` bills **per network swept**, so a bare "monitor my brand"
256
261
  sweeps all nine for 21 credits.
257
- - `catch_up_watchlist` bills **per creator**, so the price is the length of a
258
- list the request never mentions.
259
-
260
- Above 6 credits those two ask first, over MCP `elicitation` — the client shows
261
- the number and you accept or decline. Declining spends nothing and is not an
262
- error. A client that does not support elicitation is not blocked; the call runs
263
- as it always did.
262
+ - `catch_up_watchlist` bills **per creator**, so the price is the length of your
263
+ watchlist.
264
+ - `answer_my_audience` and `what_should_i_make_next` bill **per post opened**
265
+ (14 and 12 credits at their defaults), so they ask once the real post count is
266
+ known rather than on a worst case.
267
+ - `search_spoken_mentions` bills per network, per handle and per transcript, so
268
+ it asks on the worst case it could reach.
269
+ - `create_brand_watch` asks **however small the number is**, because what is
270
+ being agreed to is a recurring charge rather than one run.
271
+
272
+ Above 6 credits those first five ask first, over MCP `elicitation` — the client
273
+ shows the number and you accept or decline. Declining spends nothing and is not
274
+ an error. A client that does not support elicitation is not blocked; the call
275
+ runs as it always did.
276
+
277
+ Two rules go further than the threshold. A scheduled watch always asks, because
278
+ the standing arrangement is the thing being approved. And a watch that emails a
279
+ digest refuses to accept a `deliverTo` address unless a person actually saw the
280
+ dialog and said yes — "nobody could be asked" is a fine answer for a spend and
281
+ the wrong one for an address.
264
282
 
265
283
  ## Prerequisites
266
284
 
@@ -16,6 +16,12 @@
16
16
  */
17
17
  import { z } from "zod";
18
18
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
19
+ // Both of these shipped view-less. The grant a connection actually carries —
20
+ // read, publish, manage comments, each yes/no/unknown — is a table, and a
21
+ // table read out as prose is the thing a view is for; the third value
22
+ // especially, since "unknown" means nobody recorded the scope and must never
23
+ // be drawn as the refusal it is not.
24
+ import { viewMeta } from "./view-meta.js";
19
25
  function toResult(proxy) {
20
26
  const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
21
27
  return {
@@ -32,6 +38,7 @@ function failed(prefix, err) {
32
38
  }
33
39
  export function registerConnectionTools(server, makeClient) {
34
40
  server.registerTool("list_social_connections", {
41
+ _meta: viewMeta("list_social_connections"),
35
42
  title: "List Social Connections",
36
43
  description: "List the social accounts your workspace has connected and what each connection is " +
37
44
  "allowed to do — read the account, publish a post, manage comments. Each answer is yes, " +
@@ -52,6 +59,7 @@ export function registerConnectionTools(server, makeClient) {
52
59
  }
53
60
  });
54
61
  server.registerTool("connect_social_account", {
62
+ _meta: viewMeta("connect_social_account"),
55
63
  title: "Connect Social Account",
56
64
  description: "Get a link to open so you can connect one social account. Takes the platform, and " +
57
65
  "optionally influencerId or appId when your workspace has more than one creator profile " +
@@ -35,6 +35,23 @@
35
35
  * documentation; it is the steering, and it lands in the model's context.
36
36
  */
37
37
  import { BACKEND_CALL_CREDITS, costOf } from "./spend.js";
38
+ import { numberOf } from "./performance.js";
39
+ /**
40
+ * Every fan-out is capped; the cap is an argument, and the argument is clamped.
41
+ *
42
+ * Lives here rather than beside its first caller in jobs.ts because the
43
+ * evidence plans need it too, and for a while did not have it: three of them
44
+ * documented a `max` in their zod schema and then passed the argument straight
45
+ * through, so the ceiling a host read was never enforced and `limit: 500`
46
+ * fetched 500. One clamp for both files, and the import already runs this way
47
+ * round — jobs.ts imports evidence.ts, never the reverse.
48
+ */
49
+ export function clamp(value, fallback, min, max) {
50
+ const n = Math.floor(numberOf(value ?? fallback));
51
+ if (!n)
52
+ return fallback;
53
+ return Math.min(max, Math.max(min, n));
54
+ }
38
55
  const url = (a) => String(a.url ?? "");
39
56
  /**
40
57
  * Platforms whose accounts can be found by NAME. This is the backend's
@@ -217,24 +234,33 @@ export const EVIDENCE_PLANS = {
217
234
  args: (a) => ({
218
235
  username: String(a.username ?? ""),
219
236
  platform: a.platform,
220
- limit: Number(a.limit ?? 12),
237
+ limit: clamp(a.limit, 12, 1, 30),
221
238
  }),
222
- guidance: (a) => [
223
- `Recent posts by ${a.username}, with their stats.`,
224
- "",
225
- "Work out this creator's niche, recurring themes, hook formula, what",
226
- "over- and under-performs for them, and who their audience is. Use the",
227
- "spread of the numbers, not just the best post. Name the posts you are",
228
- "reasoning from.",
229
- ownIt,
230
- ].join("\n"),
239
+ guidance: (a) => {
240
+ const focus = String(a.focus ?? "").trim();
241
+ return [
242
+ `Recent posts by ${a.username}, with their stats.`,
243
+ "",
244
+ "Work out this creator's niche, recurring themes, hook formula, what",
245
+ "over- and under-performs for them, and who their audience is. Use the",
246
+ "spread of the numbers, not just the best post. Name the posts you are",
247
+ "reasoning from.",
248
+ ...(focus
249
+ ? [
250
+ "",
251
+ `Focus for this pass, as asked: ${focus}. Still cover the profile as a whole, but weight what you cover toward this.`,
252
+ ]
253
+ : []),
254
+ ownIt,
255
+ ].join("\n");
256
+ },
231
257
  },
232
258
  find_hook_pattern: {
233
259
  via: "get_user_posts",
234
260
  args: (a) => ({
235
261
  username: String(a.username ?? ""),
236
262
  platform: a.platform,
237
- limit: Number(a.limit ?? 12),
263
+ limit: clamp(a.limit, 12, 1, 40),
238
264
  }),
239
265
  guidance: (a) => [
240
266
  `Recent posts by ${a.username}. Their opening lines are the material.`,
@@ -251,7 +277,7 @@ export const EVIDENCE_PLANS = {
251
277
  args: (a) => ({
252
278
  niche: String(a.niche ?? ""),
253
279
  platform: a.platform,
254
- limit: Number(a.count ?? 12),
280
+ limit: clamp(a.count, 12, 1, 40),
255
281
  }),
256
282
  guidance: (a) => [
257
283
  `Recent posts in the "${a.niche}" niche, with their stats.`,
@@ -56,19 +56,12 @@
56
56
  import { z } from "zod";
57
57
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
58
58
  import { platformFromUrl, postSlug } from "./comment-review.js";
59
- import { handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
60
- import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, } from "./spend.js";
59
+ import { clamp, handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
60
+ import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, } from "./spend.js";
61
61
  import { distributionOf, excluding, numberOf, standing, } from "./performance.js";
62
62
  import { normaliseHandle, watchEntryId, watchlistOwner } from "./watchlist.js";
63
63
  import { viewMeta } from "./view-meta.js";
64
64
  import { extractLinks, COLLAB_RUBRIC, vettingGuidance } from "./collab.js";
65
- /** Every fan-out is capped; the cap is an argument, and the argument is clamped. */
66
- function clamp(value, fallback, min, max) {
67
- const n = Math.floor(numberOf(value ?? fallback));
68
- if (!n)
69
- return fallback;
70
- return Math.min(max, Math.max(min, n));
71
- }
72
65
  const rowsOf = (value) => Array.isArray(value) ? value.filter((r) => r && typeof r === "object") : [];
73
66
  const structuredOf = (res) => (res.structured ?? {});
74
67
  const reason = (err) => (err instanceof Error ? err.message : String(err));
@@ -482,6 +475,48 @@ function nextGuidance(a) {
482
475
  function escapeRegExp(s) {
483
476
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
484
477
  }
478
+ /** "4:12", or "1:04:12" once there is an hour to show. */
479
+ export function formatTimecode(ms) {
480
+ const total = Math.max(0, Math.floor(ms / 1000));
481
+ const s = total % 60;
482
+ const m = Math.floor(total / 60) % 60;
483
+ const h = Math.floor(total / 3600);
484
+ const pad = (n) => String(n).padStart(2, "0");
485
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
486
+ }
487
+ /**
488
+ * The cue a character offset falls in — the line the term was said in.
489
+ *
490
+ * The cues are sorted by offset, so this keeps the last one that starts at or
491
+ * before the match and stops at the first that starts after it. A match
492
+ * landing in the gap before the first cue has no answer, which is the honest
493
+ * one.
494
+ */
495
+ function cueAt(cues, position) {
496
+ let found = null;
497
+ for (const cue of cues) {
498
+ if (cue.offset > position)
499
+ break;
500
+ found = cue;
501
+ }
502
+ return found;
503
+ }
504
+ /** Attach the moment each excerpt was said, where the track carried one. */
505
+ export function timeExcerpts(excerpts, rawCues) {
506
+ const cues = (Array.isArray(rawCues) ? rawCues : [])
507
+ .filter((c) => !!c && typeof c === "object"
508
+ && typeof c.offset === "number"
509
+ && typeof c.startMs === "number")
510
+ .sort((a, b) => a.offset - b.offset);
511
+ if (!cues.length)
512
+ return excerpts;
513
+ return excerpts.map((ex) => {
514
+ const cue = cueAt(cues, ex.position);
515
+ if (!cue)
516
+ return ex;
517
+ return { ...ex, startMs: cue.startMs, timecode: formatTimecode(cue.startMs) };
518
+ });
519
+ }
485
520
  /**
486
521
  * Where a term is actually said, case-insensitively and on whole words —
487
522
  * "nike" must not fire inside "nikeisha". `\b` only means anything next to a
@@ -1530,9 +1565,12 @@ export function registerJobTools(server, makeClient, store) {
1530
1565
  "speech recognition — see get_post_transcript), and searches them for a term. Narrows to " +
1531
1566
  "candidate posts first (a niche/keyword sweep, named creator handles, and/or your " +
1532
1567
  "watchlist), transcribes only the most-viewed survivors up to a hard ceiling you set, and " +
1533
- "returns the matched line with surrounding context so tone can be judged. TikTok and " +
1534
- "YouTube only — the only two networks whose posts carry a caption track this cheaply — " +
1535
- "and even there a video with no captions is invisible to this tool; coverage is real but " +
1568
+ "returns the matched line with surrounding context so tone can be judged. TikTok, YouTube " +
1569
+ "and Douyin — the networks whose post detail already carries a caption track, which is " +
1570
+ "what makes a transcript one cheap read rather than a wait. Every other network's audio " +
1571
+ "can be transcribed too, at the same price, but not yet from here: see " +
1572
+ "get_post_transcript for that route. Even on these three a video whose creator disabled " +
1573
+ "captions is invisible to this tool; coverage is real but " +
1536
1574
  "partial, and the result says how many candidates were found, transcribed and matched so " +
1537
1575
  "the gap is never silent. Costs 2 nooticr credits per platform a niche is searched on, 2 " +
1538
1576
  "per creator handle checked (including ones added by useWatchlist), and 1 per transcript " +
@@ -1549,7 +1587,7 @@ export function registerJobTools(server, makeClient, store) {
1549
1587
  .describe("Brand, product or person to listen for, e.g. 'nooticr'. Matched case-insensitively " +
1550
1588
  "on whole words, so 'nike' will not match inside 'nikeisha'."),
1551
1589
  platforms: z
1552
- .array(z.enum(["tiktok", "youtube"]))
1590
+ .array(z.enum(SPOKEN_PLATFORMS))
1553
1591
  .optional()
1554
1592
  .describe("Which networks to check (default: both). These are the only two whose posts carry " +
1555
1593
  "a caption track this tool can read."),
@@ -1594,8 +1632,8 @@ export function registerJobTools(server, makeClient, store) {
1594
1632
  };
1595
1633
  }
1596
1634
  const platforms = [
1597
- ...new Set((args.platforms && args.platforms.length ? args.platforms : ["tiktok", "youtube"]).map((p) => String(p).toLowerCase())),
1598
- ].filter((p) => p === "tiktok" || p === "youtube");
1635
+ ...new Set((args.platforms && args.platforms.length ? args.platforms : SPOKEN_PLATFORMS).map((p) => String(p).toLowerCase())),
1636
+ ].filter((p) => SPOKEN_PLATFORMS.includes(p));
1599
1637
  const explicitHandles = [...new Set((args.usernames ?? []).map(normaliseHandle).filter(Boolean))];
1600
1638
  // A watchlist handle already knows its own platform, so it is checked
1601
1639
  // once there rather than once per requested platform — the same
@@ -1780,7 +1818,12 @@ export function registerJobTools(server, makeClient, store) {
1780
1818
  post: { ...post, postId: id, postedAt: postDate(post) },
1781
1819
  postId: id,
1782
1820
  matchCount,
1783
- excerpts,
1821
+ // TikTok and YouTube both publish a timed caption track, and
1822
+ // those are the only two platforms this tool narrows to — so a
1823
+ // hit here almost always knows the moment it was said. The
1824
+ // speech-to-text fallback carries no clock, and an excerpt from
1825
+ // one simply arrives without a timecode.
1826
+ excerpts: timeExcerpts(excerpts, structured.cues),
1784
1827
  wordCount: numberOf(structured.wordCount),
1785
1828
  language: structured.language ?? null,
1786
1829
  autoGenerated: !!structured.autoGenerated,