@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.
@@ -0,0 +1,365 @@
1
+ /**
2
+ * What each tool is about to do, and what it will cost — for the wait.
3
+ *
4
+ * ## Why this exists rather than a table in the view
5
+ *
6
+ * The view is a static HTML string with no imports, so its first version of
7
+ * this was a hand-copied price list. It was wrong within the hour: it took its
8
+ * numbers from `mcp_tool_cost` in nooticr-server, which prices a *direct*
9
+ * `/mcp` call, while every evidence tool in this server fans out to cheaper
10
+ * calls instead. `analyze_post` is billed 6 there and 3 here (frames 2 plus
11
+ * transcript 1); `analyze_creator_profile` 15 there and 2 here. A price on the
12
+ * face of a loading screen is a promise, and one copied from the wrong table
13
+ * is worse than none at all.
14
+ *
15
+ * So the plans are built here, from the same constants the tools bill against
16
+ * — `EVIDENCE_PLANS` and `BACKEND_CALL_CREDITS` — and substituted into the
17
+ * template as JSON. A tool that changes what it fetches changes what the wait
18
+ * says, with no second place to remember.
19
+ *
20
+ * ## What the view may and may not claim
21
+ *
22
+ * MCP Apps gives a view four notifications: tool-input, tool-input-partial,
23
+ * tool-result and tool-cancelled (`@modelcontextprotocol/ext-apps`). There is
24
+ * no progress notification, so nothing here can honestly say a step has
25
+ * *finished* — only what the call is going to do and what each line costs.
26
+ * The design board's ledger ticks green checks through its rows; that is a
27
+ * canvas loop, and reproducing it would assert completions this server has no
28
+ * way to know. The rows are drawn, priced, and left un-ticked.
29
+ */
30
+ import { planCalls } from "./evidence.js";
31
+ import { BACKEND_CALL_CREDITS, CREDITS_PER_CREATOR, CREDITS_PER_NETWORK, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SEARCH_PLATFORMS, SPOKEN_PLATFORMS, XIAOHONGSHU_CREDITS, } from "./spend.js";
32
+ /** What each backend call is doing, in words a person reads rather than a tool name. */
33
+ const CALL_LABELS = {
34
+ get_post_frames: { label: "Sampling frames", detail: "one per shot, by scene change" },
35
+ get_post_transcript: { label: "Reading the caption track", detail: "exact wording, not inferred" },
36
+ get_social_media: { label: "Fetching the post", detail: "stats, caption and media" },
37
+ get_user_posts: { label: "Loading their posts", detail: "recent window, with stats" },
38
+ get_post_comments: { label: "Reading the comments", detail: "top replies and their themes" },
39
+ discover_social_posts: { label: "Searching posts", detail: "recent, for the niche" },
40
+ search_creators: { label: "Finding creators", detail: "by keyword" },
41
+ get_similar_creators: { label: "Finding lookalikes", detail: "from the seed creator" },
42
+ discover_sounds: { label: "Finding sounds", detail: "trending audio" },
43
+ discover_hashtags: { label: "Reading the trend board", detail: "with volumes and direction" },
44
+ };
45
+ function step(via) {
46
+ const said = CALL_LABELS[via] ?? { label: via };
47
+ return { via, label: said.label, detail: said.detail, credits: BACKEND_CALL_CREDITS[via] ?? 0 };
48
+ }
49
+ /** The fan-out an evidence tool declares, as priced lines. */
50
+ function evidenceSteps(tool) {
51
+ return planCalls(tool).map(step);
52
+ }
53
+ const CREATOR_UNIT = {
54
+ via: "get_user_posts",
55
+ label: "Checking a creator",
56
+ detail: "since your last catch-up",
57
+ credits: CREDITS_PER_CREATOR,
58
+ defaultCount: 1,
59
+ };
60
+ /**
61
+ * One plan per registered tool.
62
+ *
63
+ * Pinned by tests/loading-plans.test.ts against the server's own tool list, so
64
+ * a tool cannot ship without a wait that says what it is doing — which is how
65
+ * search_mentions, the slowest and dearest call here, ended up drawing a
66
+ * generic grey box that said "Working".
67
+ */
68
+ export const LOADING_PLANS = {
69
+ // ─── Read a post ───
70
+ get_social_media: { label: "Fetching the post", kind: "post", n: 1, steps: [step("get_social_media")] },
71
+ get_post_transcript: {
72
+ label: "Reading the caption track",
73
+ kind: "text",
74
+ n: 1,
75
+ steps: [step("get_post_transcript")],
76
+ },
77
+ get_post_frames: { label: "Sampling frames", kind: "post", n: 1, steps: [step("get_post_frames")] },
78
+ get_post_comments: { label: "Loading comments", kind: "list", n: 6, steps: [step("get_post_comments")] },
79
+ // ─── Understand a post ───
80
+ analyze_post: { label: "Analysing the post", kind: "post", n: 1, steps: evidenceSteps("analyze_post") },
81
+ understand_social_post: {
82
+ label: "Watching the video",
83
+ kind: "post",
84
+ n: 1,
85
+ steps: evidenceSteps("understand_social_post"),
86
+ },
87
+ analyze_post_fast: {
88
+ label: "Reading the post",
89
+ kind: "text",
90
+ n: 1,
91
+ steps: evidenceSteps("analyze_post_fast"),
92
+ },
93
+ analyze_comments: {
94
+ label: "Reading the comment section",
95
+ kind: "text",
96
+ n: 1,
97
+ // Not an evidence plan: it proxies one get_post_comments and synthesises.
98
+ steps: [step("get_post_comments")],
99
+ },
100
+ compare_posts: {
101
+ label: "Fetching the first post",
102
+ kind: "strip",
103
+ n: 2,
104
+ steps: evidenceSteps("compare_posts"),
105
+ note: "1 credit more for every further post you fetch yourself.",
106
+ },
107
+ // ─── Research ───
108
+ discover_social_posts: {
109
+ label: "Searching posts",
110
+ kind: "strip",
111
+ n: 3,
112
+ steps: [step("discover_social_posts")],
113
+ },
114
+ get_user_posts: { label: "Loading their posts", kind: "strip", n: 3, steps: [step("get_user_posts")] },
115
+ search_creators: { label: "Finding creators", kind: "list", n: 5, steps: [step("search_creators")] },
116
+ get_similar_creators: {
117
+ label: "Finding similar creators",
118
+ kind: "list",
119
+ n: 5,
120
+ steps: [step("get_similar_creators")],
121
+ },
122
+ discover_sounds: { label: "Finding trending sounds", kind: "list", n: 4, steps: [step("discover_sounds")] },
123
+ discover_hashtags: {
124
+ label: "Reading the trend board",
125
+ kind: "list",
126
+ n: 6,
127
+ steps: [step("discover_hashtags")],
128
+ },
129
+ analyze_creator_profile: {
130
+ label: "Reading the profile",
131
+ kind: "list",
132
+ n: 4,
133
+ steps: evidenceSteps("analyze_creator_profile"),
134
+ },
135
+ find_hook_pattern: {
136
+ label: "Reading their openings",
137
+ kind: "list",
138
+ n: 5,
139
+ steps: evidenceSteps("find_hook_pattern"),
140
+ },
141
+ niche_report: { label: "Reading the niche", kind: "text", n: 1, steps: evidenceSteps("niche_report") },
142
+ // ─── Make something ───
143
+ write_hooks: { label: "Reading the source post", kind: "text", n: 1, steps: evidenceSteps("write_hooks") },
144
+ create_variants: {
145
+ label: "Reading the post that worked",
146
+ kind: "text",
147
+ n: 1,
148
+ steps: evidenceSteps("create_variants"),
149
+ },
150
+ repurpose_post: {
151
+ label: "Reading the source post",
152
+ kind: "text",
153
+ n: 1,
154
+ steps: evidenceSteps("repurpose_post"),
155
+ },
156
+ // Fetches nothing: the draft is already the caller's.
157
+ score_draft: { label: "Scoring the draft", kind: "text", n: 1, steps: [], free: true },
158
+ // ─── Brand monitoring ───
159
+ search_mentions: {
160
+ label: "Sweeping for mentions",
161
+ kind: "list",
162
+ n: 6,
163
+ steps: [],
164
+ perUnit: [
165
+ {
166
+ via: "search_mentions",
167
+ label: "Sweeping a network",
168
+ detail: "every comment that names the term",
169
+ credits: CREDITS_PER_NETWORK,
170
+ arg: "platforms",
171
+ defaultCount: SEARCH_PLATFORMS.length,
172
+ perPlatform: true,
173
+ },
174
+ ],
175
+ note: "Priced per network. Omitting platforms means all of them.",
176
+ },
177
+ // The one plan whose arithmetic is not a single argument read straight off:
178
+ // it mirrors the worst case jobs.ts computes before it asks to spend, line
179
+ // for line, because the two numbers are shown to the same person minutes
180
+ // apart and any gap between them reads as one of them lying.
181
+ search_spoken_mentions: {
182
+ label: "Listening for mentions",
183
+ kind: "list",
184
+ n: 4,
185
+ steps: [],
186
+ perUnit: [
187
+ {
188
+ via: "discover_social_posts",
189
+ label: "Sweeping for candidates",
190
+ detail: "per network, when a niche is given",
191
+ credits: BACKEND_CALL_CREDITS.discover_social_posts,
192
+ arg: "platforms",
193
+ only: SPOKEN_PLATFORMS,
194
+ defaultCount: SPOKEN_PLATFORMS.length,
195
+ onlyWith: "niche",
196
+ },
197
+ {
198
+ via: "get_user_posts",
199
+ label: "Checking a creator",
200
+ detail: "once per network asked for",
201
+ credits: BACKEND_CALL_CREDITS.get_user_posts,
202
+ arg: "usernames",
203
+ per: { arg: "platforms", only: SPOKEN_PLATFORMS, defaultCount: SPOKEN_PLATFORMS.length },
204
+ defaultCount: 0,
205
+ max: MAX_SPOKEN_HANDLE_CALLS,
206
+ ceilingWith: "useWatchlist",
207
+ },
208
+ {
209
+ via: "get_post_transcript",
210
+ label: "Transcribing a candidate",
211
+ detail: "most-viewed first, up to the ceiling",
212
+ credits: BACKEND_CALL_CREDITS.get_post_transcript,
213
+ arg: "maxTranscripts",
214
+ defaultCount: 8,
215
+ min: 1,
216
+ max: MAX_SPOKEN_TRANSCRIPTS,
217
+ },
218
+ ],
219
+ note: "The ceiling is the price. Only the survivors are transcribed.",
220
+ },
221
+ catch_up_watchlist: {
222
+ label: "Checking your watchlist",
223
+ kind: "list",
224
+ n: 5,
225
+ steps: [],
226
+ perUnit: [CREATOR_UNIT],
227
+ note: "Priced per creator on the watchlist.",
228
+ },
229
+ // ─── The goal tools (jobs.ts) ───
230
+ answer_my_audience: {
231
+ label: "Reading your own replies",
232
+ kind: "list",
233
+ n: 6,
234
+ steps: [step("get_user_posts")],
235
+ perUnit: [
236
+ {
237
+ via: "get_post_comments",
238
+ label: "Opening a post",
239
+ detail: "reading what was asked under it",
240
+ credits: BACKEND_CALL_CREDITS.get_post_comments,
241
+ arg: "limit",
242
+ defaultCount: 6,
243
+ },
244
+ ],
245
+ },
246
+ track_competitor: {
247
+ label: "Reading what they shipped",
248
+ kind: "strip",
249
+ n: 3,
250
+ steps: [step("get_user_posts")],
251
+ },
252
+ who_should_i_work_with: {
253
+ label: "Building a shortlist",
254
+ kind: "list",
255
+ n: 5,
256
+ steps: [step("search_creators")],
257
+ perUnit: [
258
+ {
259
+ via: "get_similar_creators",
260
+ label: "Adding their lookalikes",
261
+ credits: BACKEND_CALL_CREDITS.get_similar_creators,
262
+ defaultCount: 1,
263
+ onlyWith: "seed",
264
+ },
265
+ ],
266
+ },
267
+ why_did_this_underperform: {
268
+ label: "Measuring it against their own median",
269
+ kind: "text",
270
+ n: 1,
271
+ steps: [step("get_social_media"), step("get_user_posts")],
272
+ },
273
+ what_should_i_make_next: {
274
+ label: "Reading demand against supply",
275
+ kind: "list",
276
+ n: 6,
277
+ steps: [step("get_user_posts"), step("discover_social_posts")],
278
+ perUnit: [
279
+ {
280
+ via: "get_post_comments",
281
+ label: "Reading a post's comments",
282
+ detail: "what the audience asked for",
283
+ credits: BACKEND_CALL_CREDITS.get_post_comments,
284
+ arg: "limit",
285
+ defaultCount: 4,
286
+ },
287
+ ],
288
+ },
289
+ // ─── Draws what the caller already has: no fetch, no price ───
290
+ show_comment_review: { label: "Drawing the labels", kind: "list", n: 6, steps: [], free: true },
291
+ show_audience_replies: { label: "Laying out the drafts", kind: "list", n: 5, steps: [], free: true },
292
+ // ─── Draws what the caller already produced: no fetch, no price ───
293
+ // The show_* family renders the model's own output. A wait here is brief
294
+ // and free, but it still says which, because "nothing is happening" and
295
+ // "this is free and instant" look identical on a blank screen.
296
+ show_analysis: { label: "Drawing the reading", kind: "text", n: 1, steps: [], free: true },
297
+ show_comparison: { label: "Drawing the comparison", kind: "strip", n: 2, steps: [], free: true },
298
+ show_hooks: { label: "Laying out the hooks", kind: "list", n: 5, steps: [], free: true },
299
+ show_variants: { label: "Laying out the variants", kind: "list", n: 4, steps: [], free: true },
300
+ show_repurposed_post: { label: "Laying out the rewrites", kind: "text", n: 1, steps: [], free: true },
301
+ show_collab_shortlist: { label: "Drawing the shortlist", kind: "list", n: 5, steps: [], free: true },
302
+ prepare_handoff: { label: "Packaging the handoff", kind: "list", n: 4, steps: [], free: true },
303
+ // ─── Own-account reads: nooticr's own stored rows ───
304
+ get_scheduled_posts: { label: "Reading your pipeline", kind: "list", n: 5, steps: [], free: true },
305
+ get_post_performance: { label: "Reading your numbers", kind: "list", n: 5, steps: [], free: true },
306
+ get_video_stats: { label: "Reading the last sync", kind: "strip", n: 3, steps: [], free: true },
307
+ get_brand_playbook: { label: "Reading the playbook", kind: "text", n: 1, steps: [], free: true },
308
+ // ─── The product analysis: the one own-account tool that spends ───
309
+ // The only outbound fetch in this family — it reads an excerpt of the
310
+ // product's own site — and it is billed against the workspace's plan AI
311
+ // credits, not the personal MCP balance.
312
+ analyze_product: { label: "Starting the analysis", kind: "text", n: 1, steps: [], planAi: true },
313
+ // Free to poll: the spend already happened when the job was started.
314
+ analyze_product_status: {
315
+ label: "Checking the analysis",
316
+ kind: "text",
317
+ n: 1,
318
+ steps: [],
319
+ free: true,
320
+ },
321
+ // ─── Scheduled monitoring (brand-watch.ts) ───
322
+ // Managing a schedule is not using it: the sweeps a watch makes are billed
323
+ // as search_mentions when the worker runs them, later and elsewhere.
324
+ create_brand_watch: { label: "Setting up the watch", kind: "text", n: 1, steps: [], free: true },
325
+ list_brand_watches: { label: "Reading your watches", kind: "list", n: 3, steps: [], free: true },
326
+ stop_brand_watch: { label: "Stopping the watch", kind: "text", n: 1, steps: [], free: true },
327
+ // ─── Own-account reads: nooticr's own stored rows, never an upstream call ───
328
+ list_own_apps: { label: "Reading your products", kind: "list", n: 3, steps: [], free: true },
329
+ get_content_plan: { label: "Reading the saved plan", kind: "text", n: 1, steps: [], free: true },
330
+ // Calls AI, but the dashboard's own pre-publish review has never billed for
331
+ // it, so neither does this.
332
+ review_post: { label: "Scoring the post", kind: "text", n: 1, steps: [], free: true },
333
+ // ─── Own-account generation: a different balance ───
334
+ draft_post: { label: "Drafting the post", kind: "text", n: 1, steps: [], planAi: true },
335
+ growth_brief: { label: "Writing the brief", kind: "text", n: 1, steps: [], planAi: true },
336
+ generate_content_plan: {
337
+ label: "Planning the week",
338
+ kind: "list",
339
+ n: 5,
340
+ steps: [],
341
+ planAi: true,
342
+ },
343
+ generate_captions: { label: "Writing the captions", kind: "text", n: 1, steps: [], planAi: true },
344
+ // ─── Account and own-account: free, and free at a zero balance ───
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
+ nooticr_login: { label: "Getting a sign-in link", kind: "text", n: 1, steps: [], free: true },
348
+ watch_creator: { label: "Adding to your watchlist", kind: "text", n: 1, steps: [], free: true },
349
+ unwatch_creator: { label: "Removing from your watchlist", kind: "text", n: 1, steps: [], free: true },
350
+ list_social_connections: { label: "Reading your connections", kind: "list", n: 3, steps: [], free: true },
351
+ connect_social_account: { label: "Minting a fresh link", kind: "text", n: 1, steps: [], free: true },
352
+ create_product: { label: "Writing the row", kind: "text", n: 1, steps: [], free: true },
353
+ update_product: { label: "Patching the row", kind: "text", n: 1, steps: [], free: true },
354
+ };
355
+ /** Everything the view needs, as one JSON blob substituted into the template. */
356
+ export function loadingPlansJson() {
357
+ return JSON.stringify({
358
+ plans: LOADING_PLANS,
359
+ platforms: SEARCH_PLATFORMS,
360
+ perNetwork: CREDITS_PER_NETWORK,
361
+ xiaohongshu: XIAOHONGSHU_CREDITS,
362
+ });
363
+ }
364
+ /** Tools that fan out over an argument, for the tests that pin the wait. */
365
+ export const ARGUMENT_PRICED = Object.keys(LOADING_PLANS).filter((t) => !!LOADING_PLANS[t].perUnit);
@@ -587,6 +587,9 @@ export const OUTPUT_SCHEMAS = {
587
587
  text: scalar().describe("The spoken line, with surrounding context to judge tone from."),
588
588
  position: scalar().describe("Character offset of the match inside the full transcript."),
589
589
  occurrence: scalar().describe("Which match this is, 1-based. Only the first few carry an excerpt."),
590
+ startMs: scalar().describe("When it was said, from the caption track's own cue timings. Absent, never " +
591
+ "estimated, when the platform published no timings."),
592
+ timecode: scalar().describe('The same instant as "4:12".'),
590
593
  })),
591
594
  wordCount: scalar(),
592
595
  language: scalar(),
@@ -599,6 +602,16 @@ export const OUTPUT_SCHEMAS = {
599
602
  }),
600
603
  get_post_transcript: open({
601
604
  available: scalar().describe("false when the post carries no caption track."),
605
+ // Deliberately no text: it is already in `transcript`, and a caption
606
+ // track is long enough that sending it twice would be a real cost in a
607
+ // payload that ends up in a model's context. `offset` is what makes that
608
+ // safe — it indexes into the transcript that is there.
609
+ cues: listOf(open({
610
+ startMs: scalar().describe("When this line starts, in milliseconds."),
611
+ endMs: scalar(),
612
+ offset: scalar().describe("Where this line's words begin in `transcript`, in UTF-16 code units — so a match " +
613
+ "found at a string index maps back to the moment it was said."),
614
+ })).describe("Cue timings, where the platform published a timed track. Absent for speech-to-text."),
602
615
  transcript: scalar(),
603
616
  wordCount: scalar(),
604
617
  language: scalar(),
@@ -87,6 +87,7 @@ export function registerOwnAccountTools(server, makeClient) {
87
87
  }
88
88
  });
89
89
  server.registerTool("create_product", {
90
+ _meta: viewMeta("create_product"),
90
91
  title: "Create Product",
91
92
  description: "Create a new product (\"app\") in your own workspace — the row every other own-account " +
92
93
  "tool needs before it has anything to work with; a fresh workspace has none. Takes no " +
@@ -138,6 +139,7 @@ export function registerOwnAccountTools(server, makeClient) {
138
139
  }
139
140
  });
140
141
  server.registerTool("update_product", {
142
+ _meta: viewMeta("update_product"),
141
143
  title: "Update Product",
142
144
  description: "Patch your own product's fields — omitted arguments leave their column unchanged. Takes " +
143
145
  "appId (optional when your workspace has only one product); every other field is " +
@@ -355,7 +355,7 @@ export function registerPrompts(server) {
355
355
  `than the caption, because that is usually where a brand gets named. 2 credits per network ` +
356
356
  `swept, 5 for Xiaohongshu — a bare call with no platforms sweeps all nine for 21 credits, so ` +
357
357
  `say that number before you spend it if the user has not already narrowed it.\n` +
358
- `2. search_spoken_mentions for "${brand}"${niche ? ` narrowed by "${niche}"` : ", narrowed by the niche you just found"} — the words said out loud on TikTok and ` +
358
+ `2. search_spoken_mentions for "${brand}"${niche ? ` narrowed by "${niche}"` : ", narrowed by the niche you just found"} — the words said out loud on TikTok, Douyin and ` +
359
359
  `YouTube that search_mentions cannot see, because it only reads text. 2 credits per platform ` +
360
360
  `the niche is searched on, 2 per named handle checked, 1 per transcript actually fetched up ` +
361
361
  `to maxTranscripts — pricier than search_mentions, which is why it runs second.\n` +
@@ -10,6 +10,32 @@ export const SEARCH_PLATFORMS = [
10
10
  "reddit",
11
11
  "weibo",
12
12
  ];
13
+ /**
14
+ * The networks `search_spoken_mentions` can reach on the caption-track route.
15
+ *
16
+ * These three are the ones whose post detail already carries a caption track,
17
+ * so a transcript is one synchronous read at 1 credit —
18
+ * `TRANSCRIPT_CAPTION_PLATFORMS` in nooticr-server, mirrored here.
19
+ *
20
+ * This list is the *fast path*, not the boundary of what could be searched:
21
+ * `get_post_transcript`'s `_` arm transcribes any other platform's audio with
22
+ * whisper, behind the same tool at the same price. It stays a fast path rather
23
+ * than the whole story for two reasons that are about the deployment, not the
24
+ * network — speech-to-text is gated on a `WHISPER_MODEL` no environment sets
25
+ * yet, and the listening path byte-downloads the media, so an HLS-only
26
+ * platform (Reddit) and one that carries no media URL at all (Bilibili) cannot
27
+ * be reached even once it is configured. Widening past these three waits on
28
+ * nooticr-server#39 and #42; the earlier wording here claimed instead that
29
+ * listening was "a different tool at a different price", which was wrong on
30
+ * both halves and is why Douyin sat unreachable behind a two-network limit
31
+ * despite reading captions exactly like the other two.
32
+ *
33
+ * Named here rather than inline because three places have to agree on it — the
34
+ * tool's own enum, the filter it puts every requested platform through, and the
35
+ * wait that prices the sweep. A platform the filter drops but the price counts
36
+ * is a credit quoted for a call that never happens.
37
+ */
38
+ export const SPOKEN_PLATFORMS = ["tiktok", "youtube", "douyin"];
13
39
  /** Two per network, except the one that costs an order of magnitude more upstream. */
14
40
  export const CREDITS_PER_NETWORK = 2;
15
41
  export const XIAOHONGSHU_CREDITS = 5;
@@ -3,7 +3,7 @@ export const TOOL_DEFINITIONS = [
3
3
  {
4
4
  name: "analyze_post",
5
5
  title: "Analyze Post",
6
- description: "Frames sampled evenly across a social post (video, image, carousel/slideshow), returned as real images you can look at, together with the post's transcript, caption and stats. It hands you the material rather than a verdict: read the frames and the words, then work out the hook, the structure, the visual style, the CTA and the audience yourself, citing the frame or line behind each claim. Supports TikTok, Instagram, YouTube, X, Reddit, Douyin, Xiaohongshu, Weibo and Bilibili. Use when the visuals are the point — framing, editing, on-screen text; analyze_post_fast reads the same post without the frames for one credit less. Fans out to get_post_frames and get_post_transcript: 3 nooticr credits, 2 for the frames plus 1 for the transcript.",
6
+ description: "Frames sampled evenly across a social post (video, image, carousel/slideshow), returned as real images you can look at, together with the post's transcript, caption and stats. It hands you the material rather than a verdict: read the frames and the words, then work out the hook, the structure, the visual style, the CTA and the audience yourself, citing the frame or line behind each claim. Supports TikTok, Instagram, YouTube, X, Reddit, Douyin, Xiaohongshu, Weibo, Bilibili and LinkedIn. Use when the visuals are the point — framing, editing, on-screen text; analyze_post_fast reads the same post without the frames for one credit less. Fans out to get_post_frames and get_post_transcript: 3 nooticr credits, 2 for the frames plus 1 for the transcript.",
7
7
  inputSchema: z.object({ url: z.string().describe("Public post URL (TikTok/Instagram/YouTube/X/Reddit/Douyin/Xiaohongshu/Weibo/Bilibili/LinkedIn).") }).strict(),
8
8
  },
9
9
  {
@@ -57,7 +57,7 @@ export const TOOL_DEFINITIONS = [
57
57
  {
58
58
  name: "understand_social_post",
59
59
  title: "Understand Social Post",
60
- description: "The same frames and transcript analyze_post returns, asked a different question: describe what physically happens on screen, in order, with every observation anchored to a frame. Supports TikTok, Instagram, YouTube, X, Reddit, Douyin, Xiaohongshu, Weibo and Bilibili. Use when you need the events rather than the strategy; analyze_post puts the strategic question to the same material. Fans out to get_post_frames and get_post_transcript: 3 nooticr credits, 2 for the frames plus 1 for the transcript.",
60
+ description: "The same frames and transcript analyze_post returns, asked a different question: describe what physically happens on screen, in order, with every observation anchored to a frame. Supports TikTok, Instagram, YouTube, X, Reddit, Douyin, Xiaohongshu, Weibo, Bilibili and LinkedIn. Use when you need the events rather than the strategy; analyze_post puts the strategic question to the same material. Fans out to get_post_frames and get_post_transcript: 3 nooticr credits, 2 for the frames plus 1 for the transcript.",
61
61
  inputSchema: z.object({ url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube/X/Reddit/Douyin/Xiaohongshu/Weibo/Bilibili/LinkedIn)."), focus: z.string().optional().describe("Extra instruction, e.g. 'focus on the CTA'.") }).strict(),
62
62
  },
63
63
  {
@@ -246,18 +246,6 @@ export const TOOL_DEFINITIONS = [
246
246
  description: "List every product (\"app\") in your own workspace — id, name, niche and product type. Call this first when your workspace has more than one product and another own-account tool asks for appId. Reads only your own workspace. No cost to call.",
247
247
  inputSchema: z.object({}).strict(),
248
248
  },
249
- {
250
- name: "create_product",
251
- title: "Create Product",
252
- description: "Create a new product (\"app\") in your own workspace — the row every other own-account tool needs before it has anything to work with. Takes no workspace argument: it always creates in the calling session's own workspace. Subject to your plan's product limit. Does not generate a brand playbook by itself; call analyze_product afterwards for that. name and slug are required; description, website_url, niche, product_type and the store-listing fields are optional and snake_case. Free — no AI call, just a row.",
253
- inputSchema: z.object({ name: z.string().describe("Product name."), slug: z.string().describe("URL-safe slug, unique within your workspace."), description: z.string().optional(), website_url: z.string().optional().describe("The product's own site; analyze_product later fetches an excerpt of it."), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
254
- },
255
- {
256
- name: "update_product",
257
- title: "Update Product",
258
- description: "Patch your own product's fields — omitted arguments leave their column unchanged. Takes appId (optional with a single-app workspace); every other field is snake_case, same as create_product. Free — no AI call, just a row.",
259
- inputSchema: z.object({ appId: z.number().int().optional().describe("Your product's id. Omit only with a single-app workspace."), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), website_url: z.string().optional(), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
260
- },
261
249
  {
262
250
  name: "get_brand_playbook",
263
251
  title: "Get Brand Playbook",
@@ -381,7 +369,19 @@ export const TOOL_DEFINITIONS = [
381
369
  {
382
370
  name: "search_spoken_mentions",
383
371
  title: "Search Spoken Mentions",
384
- description: "Brand mentions people SAY but never type. search_mentions reads captions, post bodies and comments — text — so a video that names a brand only out loud is invisible to it. This reads the words actually spoken, from the platform's own caption track, and searches them for a term. Narrows to candidate posts first (a niche sweep, named creator handles, and/or your watchlist), transcribes only the most-viewed survivors up to a hard ceiling you set, and returns the matched line with surrounding context. TikTok and YouTube only — the only two networks whose posts carry a caption track this cheaply — and even there a video with no captions is invisible to this tool; the result says how many candidates were found, transcribed and matched. Use when a term might be spoken on camera but not written anywhere. Consumes 2 nooticr credits per platform a niche is searched on, 2 per creator handle checked, and 1 per transcript actually fetched, capped by maxTranscripts.",
372
+ description: "Brand mentions people SAY but never type. search_mentions reads captions, post bodies and comments — text — so a video that names a brand only out loud is invisible to it. This reads the words actually spoken, from the platform's own caption track, and searches them for a term. Narrows to candidate posts first (a niche sweep, named creator handles, and/or your watchlist), transcribes only the most-viewed survivors up to a hard ceiling you set, and returns the matched line with surrounding context. TikTok, YouTube and Douyin — the networks whose post detail already carries a caption track; every other network's audio can be transcribed at the same price but not yet from here, and even on these three a video whose creator disabled captions is invisible to this tool; the result says how many candidates were found, transcribed and matched. Use when a term might be spoken on camera but not written anywhere. Consumes 2 nooticr credits per platform a niche is searched on, 2 per creator handle checked, and 1 per transcript actually fetched, capped by maxTranscripts.",
385
373
  inputSchema: z.object({ term: z.string().describe("Brand, product or person to listen for, e.g. 'nooticr'."), platforms: z.array(z.enum(["tiktok", "youtube"])).optional().describe("Which networks to check (default: both)."), niche: z.string().optional().describe("Niche or keyword for a candidate sweep. Provide this, usernames, useWatchlist, or any combination."), usernames: z.array(z.string()).optional().describe("Specific creator handles to check."), useWatchlist: z.boolean().optional().describe("Also check your watchlisted creators on tiktok or youtube."), candidateLimit: z.number().int().optional().describe("Posts pulled per narrowing call (default 15, max 25)."), maxTranscripts: z.number().int().optional().describe("Hard ceiling on transcripts fetched (default 8, max 20).") }).strict(),
386
374
  },
375
+ {
376
+ name: "create_product",
377
+ title: "Create Product",
378
+ description: "Create a product in your own workspace \u2014 the row every other own-account tool needs before it has anything to work with; a fresh workspace has none. Takes no workspace argument: it always creates in the workspace of the session calling it, never one you could name. name and slug are required; every other field is optional and all of them are snake_case, read by exact key \u2014 a camelCase websiteUrl is not an alias, it is a field that lands nowhere. Subject to your plan's product limit; the error names the limit if you hit it. Does not generate a brand playbook. Free \u2014 no AI call, just a row.",
379
+ inputSchema: z.object({ name: z.string().describe("Product name."), slug: z.string().describe("URL-safe slug, unique within your workspace."), description: z.string().optional(), website_url: z.string().optional().describe("The product's own site; analyze_product later fetches an excerpt of it."), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
380
+ },
381
+ {
382
+ name: "update_product",
383
+ title: "Update Product",
384
+ description: "Patch your own product's fields \u2014 omitted arguments leave their column unchanged, so this cannot blank a field by not mentioning it. Takes appId, optional when your workspace has exactly one product; every other field is snake_case, the same names create_product takes and read the same way. The result lists which fields were actually written, so a name you spelled wrong shows up as a field that did not change rather than as a silent no-op. Free \u2014 no AI call, just a row.",
385
+ inputSchema: z.object({ appId: z.number().int().optional().describe("Your product's id. Omit only with a single-product workspace."), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), website_url: z.string().optional(), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
386
+ },
387
387
  ];
@@ -20,10 +20,11 @@ import { registerJobTools } from "./jobs.js";
20
20
  import { registerBrandWatch } from "./brand-watch.js";
21
21
  import { registerOwnAccountTools } from "./own-account.js";
22
22
  import { registerConnectionTools } from "./connections.js";
23
+ import { loadingPlansJson } from "./loading-plans.js";
23
24
  import { registerHandoff } from "./handoff.js";
24
25
  import { registerCollabTools } from "./collab.js";
25
26
  /** Current MCP server version — bumped on every deploy for traceability. */
26
- export const MCP_SERVER_VERSION = "1.26.21";
27
+ export const MCP_SERVER_VERSION = "1.26.23";
27
28
  /** MCP Apps extension identifier */
28
29
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
29
30
  /** MIME type for MCP Apps HTML resources */
@@ -58,8 +59,28 @@ function uiResource(tool) {
58
59
  * "Interactive View" and a generic placeholder while Claude, whose URL carries
59
60
  * the URI, named the tool. The server is the one party that always knows.
60
61
  */
62
+ /**
63
+ * The view, with the two things it cannot work out for itself baked in.
64
+ *
65
+ * The tool name, because a host that serves this HTML from its own URL leaves
66
+ * no ui:// path to read one out of. And the loading plans, because the view is
67
+ * a static string with no imports and the alternative — a price list copied by
68
+ * hand — was wrong about six tools within a day of being written.
69
+ */
70
+ const LOADING_PLANS_JSON = loadingPlansJson();
71
+ /**
72
+ * The view as a host actually receives it, for tests and tooling.
73
+ *
74
+ * The raw NOOTICR_UI_TEMPLATE still carries its `__NOOTICR_PLANS__`
75
+ * placeholder, so a browser test that renders it directly gets a view with no
76
+ * prices and no ledger — passing while the real thing is broken. Anything
77
+ * that renders the template outside the server should come through here.
78
+ */
79
+ export function uiTemplateFor(tool = "") {
80
+ return templateFor(tool);
81
+ }
61
82
  function templateFor(tool) {
62
- return NOOTICR_UI_TEMPLATE.replace("__NOOTICR_TOOL__", tool);
83
+ return NOOTICR_UI_TEMPLATE.replace("__NOOTICR_TOOL__", tool).replace('"__NOOTICR_PLANS__"', LOADING_PLANS_JSON);
63
84
  }
64
85
  /**
65
86
  * The skybridge half of a resource read.
@@ -504,8 +525,8 @@ export function createMcpServer(rawMakeClient, opts) {
504
525
  // The playbook text and a finished analysis are exactly the kind of prose
505
526
  // get_content_plan already proved the generic fallback (a formatted JSON
506
527
  // block) is an acceptable view for — see scripts/host-contract.py for why
507
- // create_product/update_product/analyze_product, which return only
508
- // metadata or a bare job-start ack, do not get one.
528
+ // analyze_product, whose immediate reply is a bare job-start ack, does not
529
+ // get one while the status tool that polls it does.
509
530
  "get_brand_playbook",
510
531
  "analyze_product_status",
511
532
  "review_post",
@@ -518,6 +539,15 @@ export function createMcpServer(rawMakeClient, opts) {
518
539
  // shortlist through the creator gallery.
519
540
  "prepare_handoff",
520
541
  "show_collab_shortlist",
542
+ // The two product writes draw the row they made and what it can reach next
543
+ // — and, for a patch, which fields actually moved, which is the one thing
544
+ // the reply says that prose reliably loses.
545
+ "create_product",
546
+ "update_product",
547
+ // The connection pair (connections.ts). Registered view-less at first; the
548
+ // grant each connection carries is a table, which is what a view is for.
549
+ "list_social_connections",
550
+ "connect_social_account",
521
551
  ];
522
552
  // Human-readable resource name per tool (used in resources/list + tools/list).
523
553
  function resourceName(tool) {
@@ -707,7 +737,7 @@ export function createMcpServer(rawMakeClient, opts) {
707
737
  "lands and who it is aimed at, citing the frame or line behind each claim. " +
708
738
  "It fans out to two fetches and you pay for both. " +
709
739
  `${costSentence("analyze_post")} Each frame costs roughly 1,200 tokens of your context. ` +
710
- "Supports TikTok, Instagram, YouTube, X, Reddit, Douyin, Xiaohongshu, Weibo and Bilibili. " +
740
+ "Supports TikTok, Instagram, YouTube, X, Reddit, Douyin, Xiaohongshu, Weibo, Bilibili and LinkedIn. " +
711
741
  "Use when the visuals are the point; analyze_post_fast reads the same post without the frames for one credit less.",
712
742
  _meta: {
713
743
  ui: { resourceUri: uiResource("analyze_post") },
@@ -866,7 +896,7 @@ export function createMcpServer(rawMakeClient, opts) {
866
896
  .enum(["tiktok", "instagram", "youtube", "douyin", "xiaohongshu", "twitter", "bilibili", "linkedin", "reddit", "weibo"])
867
897
  .optional()
868
898
  .describe("Which platform (default tiktok)."),
869
- limit: z.number().int().optional().describe("Posts to fetch (default 6; first 3 analyzed)."),
899
+ limit: z.number().int().optional().describe("Posts to fetch (default 12, max 30)."),
870
900
  focus: z.string().optional().describe("Extra instruction for the profile synthesis."),
871
901
  })
872
902
  .strict(),
@@ -1745,7 +1775,7 @@ export function createMcpServer(rawMakeClient, opts) {
1745
1775
  .object({
1746
1776
  niche: z.string().describe("Niche or topic, e.g. 'home fitness'."),
1747
1777
  platform: z.string().optional().describe("Platform to survey (default tiktok)."),
1748
- count: z.number().int().optional().describe("Posts to survey (default 20, max 40)."),
1778
+ count: z.number().int().optional().describe("Posts to survey (default 12, max 40)."),
1749
1779
  })
1750
1780
  .strict(),
1751
1781
  }, async (args, extra) => {
@@ -1777,7 +1807,7 @@ export function createMcpServer(rawMakeClient, opts) {
1777
1807
  .object({
1778
1808
  username: z.string().describe("Creator handle, with or without @."),
1779
1809
  platform: z.string().optional().describe("Platform (default tiktok)."),
1780
- limit: z.number().int().optional().describe("Posts to read (default 20, max 40)."),
1810
+ limit: z.number().int().optional().describe("Posts to read (default 12, max 40)."),
1781
1811
  })
1782
1812
  .strict(),
1783
1813
  }, async (args, extra) => {
@@ -1981,7 +2011,7 @@ export function createMcpServer(rawMakeClient, opts) {
1981
2011
  "what physically happens on screen, in order, with every observation anchored to a frame. " +
1982
2012
  "It fans out to two fetches and you pay for both. " +
1983
2013
  `${costSentence("understand_social_post")} Each frame costs roughly 1,200 tokens of your context. ` +
1984
- "Supports TikTok, Instagram, YouTube, X, Reddit, Douyin, Xiaohongshu, Weibo and Bilibili. " +
2014
+ "Supports TikTok, Instagram, YouTube, X, Reddit, Douyin, Xiaohongshu, Weibo, Bilibili and LinkedIn. " +
1985
2015
  "Use when you need the events rather than the strategy; analyze_post puts the strategic question to the same material.",
1986
2016
  _meta: {
1987
2017
  ui: { resourceUri: uiResource("understand_social_post") },