@nooticr/mcp 1.26.24 → 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 +2 -3
- package/dist/shared/hashtags.js +143 -0
- package/dist/shared/loading-plans.js +0 -1
- package/dist/shared/output-schemas.js +83 -8
- package/dist/shared/own-account.js +1 -1
- package/dist/shared/spend.js +22 -0
- package/dist/shared/tools-def.js +7 -13
- package/dist/shared/tools.js +121 -49
- package/dist/shared/ui-template.js +33 -36
- package/dist/shared/watchlist.js +6 -5
- package/package.json +12 -2
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 |
|
|
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
|
-
`
|
|
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 },
|
|
@@ -18,6 +18,49 @@
|
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
/** Loose by construction: see the note above. */
|
|
20
20
|
const open = (shape) => z.object(shape).passthrough();
|
|
21
|
+
/**
|
|
22
|
+
* A union branch that renders as its own `type`, instead of being folded into
|
|
23
|
+
* a `type` array with its siblings.
|
|
24
|
+
*
|
|
25
|
+
* `z.union([z.string(), z.number(), z.boolean()])` renders as
|
|
26
|
+
* `{ type: ["string", "number", "boolean"] }`. That is legal JSON Schema and
|
|
27
|
+
* the wrong thing to send: a client that reads `type` as a single string
|
|
28
|
+
* either rejects the tool or drops the constraint, and both failures are
|
|
29
|
+
* invisible from here — a rejected tool just stops appearing in `tools/list`,
|
|
30
|
+
* with no error this repo would ever see. The whole surface emitted 1,286 of
|
|
31
|
+
* them, from this helper and `listOf` alone.
|
|
32
|
+
*
|
|
33
|
+
* The fold happens in zod-to-json-schema's union parser, which collapses a
|
|
34
|
+
* union whose every member is a bare primitive carrying no checks. There are
|
|
35
|
+
* only two ways out, and only one of them is free:
|
|
36
|
+
*
|
|
37
|
+
* - Give a member a check. `z.number().finite()` renders as plain
|
|
38
|
+
* `{ type: "number" }` and defeats the fold, but it also stops accepting
|
|
39
|
+
* `Infinity` — which a ratio-to-baseline division by a zero median really
|
|
40
|
+
* does produce, and which this file exists to keep from turning a working
|
|
41
|
+
* call into a hard failure. Rejected for that reason, not on taste.
|
|
42
|
+
* - Give a member a wrapper whose type name is not a primitive.
|
|
43
|
+
* `.readonly()` is the one that costs nothing: on a primitive it is a
|
|
44
|
+
* runtime no-op (`Object.freeze` of a string is that string), the parsed
|
|
45
|
+
* value is discarded by the SDK's output validation anyway, and
|
|
46
|
+
* `Readonly<string>` is `string`, so nothing downstream sees a new type.
|
|
47
|
+
* It also happens to be true — these are output schemas.
|
|
48
|
+
*
|
|
49
|
+
* So the wrapper is load-bearing and is not about readonly-ness. Removing it
|
|
50
|
+
* silently reintroduces all 1,286, which is what
|
|
51
|
+
* `tests/output-schema-shape.test.ts` is there to catch.
|
|
52
|
+
*/
|
|
53
|
+
const oneType = (branch) => branch.readonly();
|
|
54
|
+
/**
|
|
55
|
+
* "This type, or null" — as `anyOf`, not as a two-element `type` array.
|
|
56
|
+
*
|
|
57
|
+
* `z.number().nullish()` renders as `{ type: ["number", "null"] }`, which is
|
|
58
|
+
* the same portability problem as `scalar()` in miniature. The three inline
|
|
59
|
+
* output schemas in `watchlist.ts` contributed 5 of the 1,286 this way, and
|
|
60
|
+
* they are the reason this is exported rather than kept private: a tool that
|
|
61
|
+
* declares its schema inline should not have to re-derive why.
|
|
62
|
+
*/
|
|
63
|
+
export const orNull = (inner) => z.union([oneType(inner), z.null()]).optional();
|
|
21
64
|
/**
|
|
22
65
|
* One scalar type for every leaf, and it accepts null.
|
|
23
66
|
*
|
|
@@ -33,10 +76,15 @@ const open = (shape) => z.object(shape).passthrough();
|
|
|
33
76
|
* agent needs from this file is which keys exist and what they mean. So the
|
|
34
77
|
* keys and their descriptions are the contract, and the types are deliberately
|
|
35
78
|
* as wide as the data can be.
|
|
79
|
+
*
|
|
80
|
+
* Which is why the branches are wrapped in `oneType()` and null is a member of
|
|
81
|
+
* the union rather than a `.nullish()` around it. Both exist to control the
|
|
82
|
+
* JSON Schema this renders as, and neither changes what the schema accepts —
|
|
83
|
+
* see `oneType` below.
|
|
36
84
|
*/
|
|
37
|
-
const scalar = () => z.union([z.string(), z.number(), z.boolean()]).
|
|
85
|
+
const scalar = () => z.union([oneType(z.string()), oneType(z.number()), oneType(z.boolean()), z.null()]).optional();
|
|
38
86
|
/** Same reasoning for lists: a null list, and a null element, must both pass. */
|
|
39
|
-
const listOf = (item) => z.array(z.union([item, z.null()])).nullish();
|
|
87
|
+
const listOf = (item) => z.array(z.union([oneType(item), z.null()])).nullish();
|
|
40
88
|
/**
|
|
41
89
|
* A list whose elements are not one shape — themes, fixes, failures.
|
|
42
90
|
*
|
|
@@ -49,6 +97,23 @@ const listOf = (item) => z.array(z.union([item, z.null()])).nullish();
|
|
|
49
97
|
* empty schema in item position, which accepts anything, which is the point.
|
|
50
98
|
*/
|
|
51
99
|
const anyList = () => z.array(z.any()).nullish();
|
|
100
|
+
/**
|
|
101
|
+
* "An object whose contents we are not constraining" — for input schemas.
|
|
102
|
+
*
|
|
103
|
+
* Exported from a file about output schemas because the concern is the same
|
|
104
|
+
* one documented above: what a zod type renders as on the wire. The natural
|
|
105
|
+
* spelling, `z.record(z.unknown())`, renders as
|
|
106
|
+
* `{ type: "object", additionalProperties: {} }`, and that `{}` carries no
|
|
107
|
+
* validation keyword at all — the object-literal spelling of a bare `true`,
|
|
108
|
+
* which reads to a strict client as an unvalidated schema rather than a
|
|
109
|
+
* deliberately open one. `z.object({}).passthrough()` accepts exactly the same
|
|
110
|
+
* values and says `additionalProperties: true`, which is the same permission
|
|
111
|
+
* stated in a keyword rather than by omission.
|
|
112
|
+
*
|
|
113
|
+
* The array counterpart needs no helper: `z.array(z.unknown())` emits
|
|
114
|
+
* `items: {}` for the same reason, and `z.array(z.any())` simply omits `items`.
|
|
115
|
+
*/
|
|
116
|
+
export const anyObject = () => z.object({}).passthrough();
|
|
52
117
|
/** Every paid tool reports what it charged. */
|
|
53
118
|
const mcpCredits = open({
|
|
54
119
|
cost: scalar().describe("Credits this call consumed."),
|
|
@@ -639,9 +704,24 @@ export const OUTPUT_SCHEMAS = {
|
|
|
639
704
|
hashtag: scalar(),
|
|
640
705
|
posts: scalar(),
|
|
641
706
|
views: scalar(),
|
|
642
|
-
|
|
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."),
|
|
643
713
|
url: scalar(),
|
|
644
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(),
|
|
645
725
|
country: scalar(),
|
|
646
726
|
days: scalar(),
|
|
647
727
|
mcpCredits,
|
|
@@ -716,13 +796,8 @@ export const OUTPUT_SCHEMAS = {
|
|
|
716
796
|
// so the description is what steers a reader off it.
|
|
717
797
|
firstFreeRemaining: listOf(z.string())
|
|
718
798
|
.describe("Superseded by firstFreeTools, which carries the same value. Kept for backward compatibility — read firstFreeTools."),
|
|
719
|
-
billingUrl: scalar(),
|
|
720
799
|
hint: scalar(),
|
|
721
800
|
}),
|
|
722
|
-
buy_nooticr_credits: open({
|
|
723
|
-
checkoutUrl: scalar(),
|
|
724
|
-
packs: listOf(open({})),
|
|
725
|
-
}),
|
|
726
801
|
nooticr_login: open({
|
|
727
802
|
signedIn: scalar().describe("true when the session is already good and no link is needed."),
|
|
728
803
|
loginUrl: scalar().describe("Only present when a sign-in is actually required."),
|
|
@@ -411,7 +411,7 @@ export function registerOwnAccountTools(server, makeClient) {
|
|
|
411
411
|
caption: z.string().optional(),
|
|
412
412
|
contentType: z.string().optional(),
|
|
413
413
|
thumbnailUrl: z.string().optional().describe("Looked at by the reviewer when given."),
|
|
414
|
-
mediaItems: z.array(z.
|
|
414
|
+
mediaItems: z.array(z.any()).optional(),
|
|
415
415
|
platform: z.string().optional(),
|
|
416
416
|
influencerId: z.number().int().optional(),
|
|
417
417
|
})
|
package/dist/shared/spend.js
CHANGED
|
@@ -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;
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -81,8 +81,8 @@ export const TOOL_DEFINITIONS = [
|
|
|
81
81
|
{
|
|
82
82
|
name: "discover_hashtags",
|
|
83
83
|
title: "Discover Hashtags",
|
|
84
|
-
description: "
|
|
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",
|
|
@@ -160,13 +160,13 @@ export const TOOL_DEFINITIONS = [
|
|
|
160
160
|
name: "show_comparison",
|
|
161
161
|
title: "Show Comparison",
|
|
162
162
|
description: "Display a comparison you wrote after compare_posts fetched the first post and you fetched the rest yourself. Free, and makes no requests — it only draws what you pass it: each post with a BEST badge on the winner, what differed, shared strengths and the next experiment worth running. Call this after you have done the comparing, not instead of it.",
|
|
163
|
-
inputSchema: z.object({ posts: z.array(z.
|
|
163
|
+
inputSchema: z.object({ posts: z.array(z.object({}).passthrough()).min(2).max(5), winner: z.number().int(), winnerReason: z.string().optional(), differences: z.array(z.object({ factor: z.string(), detail: z.string() })).optional(), lessons: z.array(z.string()).optional(), nextTest: z.string().optional() }).strict(),
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
166
|
name: "show_analysis",
|
|
167
167
|
title: "Show Analysis",
|
|
168
168
|
description: "Display an analysis you wrote after analyze_post, analyze_post_fast or understand_social_post handed you the material. Free, and makes no requests — it only draws what you pass it: hook strength, script structure, quotable lines, hashtags, target audience, viral triggers and more, whichever of these you actually produced. Call this after you have done the analysing, not instead of it.",
|
|
169
|
-
inputSchema: z.object({ url: z.string(), post: z.
|
|
169
|
+
inputSchema: z.object({ url: z.string(), post: z.object({}).passthrough().optional(), analysis: z.object({}).passthrough() }).strict(),
|
|
170
170
|
},
|
|
171
171
|
{
|
|
172
172
|
name: "show_hooks",
|
|
@@ -178,7 +178,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
178
178
|
name: "show_variants",
|
|
179
179
|
title: "Show Variants",
|
|
180
180
|
description: "Display the post variants you wrote after create_variants handed you the original post's material. Free, and makes no requests — it only draws what you pass it: each variant's hook, the angle that changes, its shot beats and its call to action. Call this after you have written the variants, not instead of writing them.",
|
|
181
|
-
inputSchema: z.object({ sourceUrl: z.string(), post: z.
|
|
181
|
+
inputSchema: z.object({ sourceUrl: z.string(), post: z.object({}).passthrough().optional(), variants: z.array(z.object({ title: z.string(), hook: z.string(), angle: z.string().optional(), beats: z.array(z.string()).optional(), cta: z.string().optional(), whyItCouldWork: z.string().optional() })).min(1) }).strict(),
|
|
182
182
|
},
|
|
183
183
|
{
|
|
184
184
|
name: "show_repurposed_post",
|
|
@@ -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
|
|
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
|
{
|
|
@@ -292,7 +286,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
292
286
|
name: "review_post",
|
|
293
287
|
title: "Review Post",
|
|
294
288
|
description: "Score a post before you publish it: hook strength, an optional A-vs-B hook comparison, aesthetic and storytelling notes, and rewritten hook/caption suggestions. Pass postId for something already in your pipeline, or appId plus draft fields otherwise. Nothing is published. Free — nothing is billed for this, same as the dashboard's own pre-publish review.",
|
|
295
|
-
inputSchema: z.object({ postId: z.number().int().optional(), appId: z.number().int().optional(), title: z.string().optional(), titleB: z.string().optional(), caption: z.string().optional(), contentType: z.string().optional(), thumbnailUrl: z.string().optional(), mediaItems: z.array(z.
|
|
289
|
+
inputSchema: z.object({ postId: z.number().int().optional(), appId: z.number().int().optional(), title: z.string().optional(), titleB: z.string().optional(), caption: z.string().optional(), contentType: z.string().optional(), thumbnailUrl: z.string().optional(), mediaItems: z.array(z.any()).optional(), platform: z.string().optional(), influencerId: z.number().int().optional() }).strict(),
|
|
296
290
|
},
|
|
297
291
|
{
|
|
298
292
|
name: "draft_post",
|
package/dist/shared/tools.js
CHANGED
|
@@ -10,11 +10,12 @@ import { z } from "zod";
|
|
|
10
10
|
import { NooticrError } from "./nooticr.js";
|
|
11
11
|
import { NOOTICR_UI_TEMPLATE } from "./ui-template.js";
|
|
12
12
|
import { registerPrompts } from "./prompts.js";
|
|
13
|
-
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
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.
|
|
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.
|
|
@@ -1350,7 +1370,7 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1350
1370
|
inputSchema: z
|
|
1351
1371
|
.object({
|
|
1352
1372
|
posts: z
|
|
1353
|
-
.array(
|
|
1373
|
+
.array(anyObject())
|
|
1354
1374
|
.min(2)
|
|
1355
1375
|
.max(5)
|
|
1356
1376
|
.describe("The 2-5 posts compared, in the order you compared them — the same shape get_social_media " +
|
|
@@ -1403,12 +1423,10 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1403
1423
|
inputSchema: z
|
|
1404
1424
|
.object({
|
|
1405
1425
|
url: z.string().describe("The post you analyzed."),
|
|
1406
|
-
post:
|
|
1407
|
-
.record(z.unknown())
|
|
1426
|
+
post: anyObject()
|
|
1408
1427
|
.optional()
|
|
1409
1428
|
.describe("The post object analyze_post/analyze_post_fast/understand_social_post handed you, unchanged."),
|
|
1410
|
-
analysis:
|
|
1411
|
-
.record(z.unknown())
|
|
1429
|
+
analysis: anyObject()
|
|
1412
1430
|
.describe("Your own analysis. Any of: summary, hookStrength (1-10), commentBaitLevel (1-10), " +
|
|
1413
1431
|
"scriptStructure {hook,buildUp,payoff,cta}, whyItWorks, suggestedHook, keyQuotes[], " +
|
|
1414
1432
|
"suggestedHashtags[], targetAudience, viralTriggers[], negativeSignals[], variationIdeas[], " +
|
|
@@ -1480,7 +1498,7 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1480
1498
|
inputSchema: z
|
|
1481
1499
|
.object({
|
|
1482
1500
|
sourceUrl: z.string().describe("The post these variants riff on."),
|
|
1483
|
-
post:
|
|
1501
|
+
post: anyObject().optional().describe("The post object create_variants handed you, unchanged."),
|
|
1484
1502
|
variants: z
|
|
1485
1503
|
.array(z.object({
|
|
1486
1504
|
title: z.string().describe("A short label for this variant."),
|
|
@@ -1569,10 +1587,15 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1569
1587
|
});
|
|
1570
1588
|
server.registerTool("discover_hashtags", {
|
|
1571
1589
|
title: "Discover Hashtags",
|
|
1572
|
-
description: "
|
|
1573
|
-
"
|
|
1574
|
-
"
|
|
1575
|
-
"
|
|
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.",
|
|
1576
1599
|
_meta: {
|
|
1577
1600
|
ui: { resourceUri: uiResource("discover_hashtags") },
|
|
1578
1601
|
"ui/resourceUri": uiResource("discover_hashtags"),
|
|
@@ -1584,16 +1607,66 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1584
1607
|
outputSchema: OUTPUT_SCHEMAS.discover_hashtags,
|
|
1585
1608
|
inputSchema: z
|
|
1586
1609
|
.object({
|
|
1587
|
-
|
|
1588
|
-
|
|
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."),
|
|
1589
1622
|
count: z.number().int().optional().describe("Max hashtags (default 20)."),
|
|
1590
1623
|
industryId: z.string().optional().describe("Optional TikTok industry id to filter by."),
|
|
1591
1624
|
})
|
|
1592
1625
|
.strict(),
|
|
1593
1626
|
}, async (args, extra) => {
|
|
1594
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
|
+
}
|
|
1595
1652
|
try {
|
|
1596
|
-
|
|
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
|
+
});
|
|
1597
1670
|
}
|
|
1598
1671
|
catch (err) {
|
|
1599
1672
|
return toolError("discover_hashtags failed", err);
|
|
@@ -1905,8 +1978,11 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1905
1978
|
});
|
|
1906
1979
|
server.registerTool("check_nooticr_credits", {
|
|
1907
1980
|
title: "Check Nooticr Credits",
|
|
1908
|
-
description: "Check your nooticr credit balance
|
|
1909
|
-
"
|
|
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.",
|
|
1910
1986
|
_meta: {
|
|
1911
1987
|
ui: { resourceUri: uiResource("check_nooticr_credits") },
|
|
1912
1988
|
"ui/resourceUri": uiResource("check_nooticr_credits"),
|
|
@@ -1920,35 +1996,31 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1920
1996
|
}, async (_args, extra) => {
|
|
1921
1997
|
const client = await makeClient(extra);
|
|
1922
1998
|
try {
|
|
1923
|
-
|
|
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);
|
|
1924
2019
|
}
|
|
1925
2020
|
catch (err) {
|
|
1926
2021
|
return toolError("check_nooticr_credits failed", err);
|
|
1927
2022
|
}
|
|
1928
2023
|
});
|
|
1929
|
-
server.registerTool("buy_nooticr_credits", {
|
|
1930
|
-
title: "Buy Nooticr Credits",
|
|
1931
|
-
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." +
|
|
1932
|
-
"Use when the balance is short and the user has agreed to top up.",
|
|
1933
|
-
_meta: {
|
|
1934
|
-
ui: { resourceUri: uiResource("buy_nooticr_credits") },
|
|
1935
|
-
"ui/resourceUri": uiResource("buy_nooticr_credits"),
|
|
1936
|
-
// ChatGPT reads only this one, and reads it to find the
|
|
1937
|
-
// text/html+skybridge twin rather than the Claude resource.
|
|
1938
|
-
"openai/outputTemplate": appsSdkResource("buy_nooticr_credits"),
|
|
1939
|
-
},
|
|
1940
|
-
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1941
|
-
outputSchema: OUTPUT_SCHEMAS.buy_nooticr_credits,
|
|
1942
|
-
inputSchema: z.object({}).strict(),
|
|
1943
|
-
}, async (_args, extra) => {
|
|
1944
|
-
const client = await makeClient(extra);
|
|
1945
|
-
try {
|
|
1946
|
-
return await toToolResult(await client.callTool("buy_nooticr_credits", {}));
|
|
1947
|
-
}
|
|
1948
|
-
catch (err) {
|
|
1949
|
-
return toolError("buy_nooticr_credits failed", err);
|
|
1950
|
-
}
|
|
1951
|
-
});
|
|
1952
2024
|
server.registerTool("nooticr_login", {
|
|
1953
2025
|
title: "Nooticr Login",
|
|
1954
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
|
-
|
|
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
|
-
|
|
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)">'+
|
|
3702
|
-
+'<span style="font-size:11px;font-weight:700;color:'+dir[1]+';white-space:nowrap">'+dir[0]+" "+esc(t.trend
|
|
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">
|
|
3707
|
-
+hr
|
|
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/dist/shared/watchlist.js
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
import { z } from "zod";
|
|
47
47
|
import { confirmSpend, declinedResult, CREDITS_PER_CREATOR } from "./spend.js";
|
|
48
48
|
import { PLATFORM_ARG } from "./evidence.js";
|
|
49
|
+
import { orNull } from "./output-schemas.js";
|
|
49
50
|
export const WATCHLIST_URI = "nooticr://watchlist";
|
|
50
51
|
/** Default, and what the tests use. Fine for a single process. */
|
|
51
52
|
export class MemoryWatchStore {
|
|
@@ -455,8 +456,8 @@ export function registerWatchlist(server, makeClient, store) {
|
|
|
455
456
|
.strict(),
|
|
456
457
|
outputSchema: z
|
|
457
458
|
.object({
|
|
458
|
-
watching: z.number()
|
|
459
|
-
added: z.string()
|
|
459
|
+
watching: orNull(z.number()),
|
|
460
|
+
added: orNull(z.string()),
|
|
460
461
|
entries: z.array(z.union([z.object({}).passthrough(), z.null()])).nullish(),
|
|
461
462
|
})
|
|
462
463
|
.passthrough(),
|
|
@@ -491,8 +492,8 @@ export function registerWatchlist(server, makeClient, store) {
|
|
|
491
492
|
.strict(),
|
|
492
493
|
outputSchema: z
|
|
493
494
|
.object({
|
|
494
|
-
removed: z.boolean()
|
|
495
|
-
watching: z.number()
|
|
495
|
+
removed: orNull(z.boolean()),
|
|
496
|
+
watching: orNull(z.number()),
|
|
496
497
|
entries: z.array(z.union([z.object({}).passthrough(), z.null()])).nullish(),
|
|
497
498
|
})
|
|
498
499
|
.passthrough(),
|
|
@@ -525,7 +526,7 @@ export function registerWatchlist(server, makeClient, store) {
|
|
|
525
526
|
.strict(),
|
|
526
527
|
outputSchema: z
|
|
527
528
|
.object({
|
|
528
|
-
checked: z.number()
|
|
529
|
+
checked: orNull(z.number()),
|
|
529
530
|
creators: z.array(z.union([z.object({}).passthrough(), z.null()])).nullish(),
|
|
530
531
|
posts: z.array(z.union([z.object({}).passthrough(), z.null()])).nullish().describe("Everything new, flattened, for the card view."),
|
|
531
532
|
mcpCredits: z.object({}).passthrough().nullish(),
|
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.
|
|
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",
|