@nooticr/mcp 1.26.26 → 1.26.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/shared/brand-watch.js +192 -0
- package/dist/shared/loading-plans.js +4 -0
- package/dist/shared/output-schemas.js +49 -0
- package/dist/shared/tools-def.js +12 -0
- package/dist/shared/tools.js +66 -1
- package/dist/shared/ui-template.js +40 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,7 @@ them caps that fan-out with an argument.
|
|
|
101
101
|
| `catch_up_watchlist` | 2 per creator | What everyone you watch has posted since your last catch-up. Compares against the snapshot taken last time and moves it forward, so it answers "what is new" rather than "what exists". |
|
|
102
102
|
| `create_brand_watch` | free to call | Schedule a recurring sweep and get emailed only what is new. `kind: "mentions"` (default) is a recurring `search_mentions` sweep for `term`/`platforms`; `kind: "competitor"` is a recurring `get_user_posts` check on one creator (`handle`/`platform`), mailing only what beats their own recent median. Two calls by design: the first returns the quote (cost per run, cadence, cost per day) and a `confirmationToken`; nothing is created or charged until you call it again with `confirm: true` and that token. Each run then bills the same as calling the matching tool yourself — 2 credits per network (5 for Xiaohongshu) for mentions, a flat 2 credits for competitor. |
|
|
103
103
|
| `list_brand_watches` | free | Every watch you have scheduled: term, networks, cadence, cost per run, credits spent so far, runs made, and when the next one is due. |
|
|
104
|
+
| `mention_trend` | free | **What a watch has seen over time**, which nothing else here answers — every other read is about now. One point per run: when it ran, how many mentions it found, how many were new, and both counts per network, so "which network is growing" and "did it go quiet or did we stop looking" come apart. On a competitor watch each point carries the median that run measured, which turns "they beat their own median" into "and that median has doubled". Plus the mentions more than one run has seen. Free because the sweeps were billed when they ran; charging again to read what was already paid for is the thing it fixes. |
|
|
104
105
|
| `stop_brand_watch` | free | Stop a watch by `watchId` or `term`. Immediate — the next run does not happen and nothing more is charged. |
|
|
105
106
|
| `niche_report` | 2 | Recent posts in a niche with their stats, so the dominant formats, hook patterns and the gaps nobody fills can be read off them. One `discover_social_posts` call. Use when deciding what to make. |
|
|
106
107
|
| `analyze_creator_profile` | 2 | A creator's recent posts with their stats — the material of a teardown: niche, themes, hook formula, what over- and underperforms, who the audience is. One `get_user_posts` call. |
|
|
@@ -112,6 +113,7 @@ them caps that fan-out with an argument.
|
|
|
112
113
|
| `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply, because no nooticr connection carries comment-write permission on any network. `limit` caps how many posts are opened, which is the price. |
|
|
113
114
|
| `prepare_handoff` | free | **The hand-off to your tracker.** Turns items you classified — a bug report in a comment, a complaint said out loud in a video — into the exact `title`, `body` and `labels` to file through whichever GitHub, Jira or Linear MCP server the host also has connected. nooticr files nothing and holds no tracker credential. The body carries the quote fenced and framed as a third-party report rather than as instructions, because the issue is read next by a coding agent; contact details are redacted, `@handles` and `#numbers` are defanged so filing notifies nobody by accident, and a `searchFirst` string dedupes a second sweep against the first. Makes no requests. |
|
|
114
115
|
| `show_audience_replies` | free | Lays your drafts out for a person to work through, grouped under the post, each with what you decided to do about it. Sends nothing; fetches nothing. |
|
|
116
|
+
| `show_trend` | free | Draws the trend your model read out of `mention_trend`. `tooShort` marks a series with too few points to call a direction, and `edgeIsRecordStart` marks a left edge that is where the record begins rather than where the conversation did — a chart hiding either is the mistake this view could otherwise make on your behalf. Makes no requests. |
|
|
115
117
|
| `track_competitor` | 2 | What a creator shipped, and which of it beat **their own** median rather than a raw view count that mostly measures follower count. One post list, whatever the window. If they are on your watchlist it also marks what is new since your last check and moves that marker forward — its own marker, not the one `catch_up_watchlist` keeps. |
|
|
116
118
|
| `who_should_i_work_with` | 2, or 4 with a seed | A collaboration shortlist: a keyword search merged with the lookalikes of a creator who already fits, marked by which search found each one. Every candidate also carries the **links pulled out of their bio**, typed and sorted by how much opening one will tell you — a repository to read the code in, their own site, a link hub that holds the real links — so vetting is reading the work rather than re-reading the follower count. Those links are never fetched here: they came out of a field the person being evaluated controls, so the host opens them, and the result says so. It does **not** measure audience overlap — that costs about nine credits a candidate, so the result says so and shows how to check a finalist rather than faking the signal. |
|
|
117
119
|
| `show_collab_shortlist` | free | Draws the candidates you scored, ranked, and asks the user which to approach. The scores are attributed to your model, not presented as a nooticr rating, and a candidate scored without anything having been opened is marked unverified. Makes no requests. |
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
17
|
+
import { viewMeta } from "./view-meta.js";
|
|
17
18
|
import { BACKEND_CALL_CREDITS, confirmSpend, declinedResult, searchMentionsCost } from "./spend.js";
|
|
18
19
|
/** Exported so prompts.ts's completion offers exactly what create_brand_watch accepts. */
|
|
19
20
|
export const CADENCES = ["hourly", "every_6_hours", "every_12_hours", "daily", "weekly"];
|
|
@@ -136,6 +137,133 @@ async function gateRecurringCharge(server, args) {
|
|
|
136
137
|
return null;
|
|
137
138
|
}
|
|
138
139
|
/** Same shaping every other proxied tool uses: text block plus structured payload. */
|
|
140
|
+
/**
|
|
141
|
+
* What to do with a run series.
|
|
142
|
+
*
|
|
143
|
+
* Three ways a reader gets this wrong, and all three produce a confident
|
|
144
|
+
* sentence about a change that did not happen:
|
|
145
|
+
*
|
|
146
|
+
* - Two points are not a trend. A watch that has run twice can only say what
|
|
147
|
+
* the second run found, and the difference between two sweeps of scraped
|
|
148
|
+
* data is mostly the scrape.
|
|
149
|
+
* - The start of the window is not the start of the conversation. It is the
|
|
150
|
+
* edge of what is kept, or the day the watch was created — a flat left
|
|
151
|
+
* edge reads as silence and is neither.
|
|
152
|
+
* - Volume found and volume mailed answer different questions. A run that
|
|
153
|
+
* found forty and mailed none is a conversation that has stopped moving,
|
|
154
|
+
* not one that has stopped.
|
|
155
|
+
*/
|
|
156
|
+
/**
|
|
157
|
+
* Which networks moved, and which way — computed, not left to the reader.
|
|
158
|
+
*
|
|
159
|
+
* `runs` comes back newest-first, which is the order it is read in and the
|
|
160
|
+
* wrong order to subtract in. Naming the direction here removes the one step
|
|
161
|
+
* where an off-by-one-end error produces a confident sentence about a change
|
|
162
|
+
* that went the other way.
|
|
163
|
+
*/
|
|
164
|
+
function perPlatformDirection(runs) {
|
|
165
|
+
const at = (r) => String(r.ranAt ?? "");
|
|
166
|
+
const chronological = [...runs].sort((a, b) => (at(a) < at(b) ? -1 : 1));
|
|
167
|
+
const foundIn = (r, platform) => {
|
|
168
|
+
const per = (r?.perPlatform ?? {});
|
|
169
|
+
const v = per[platform]?.found;
|
|
170
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
171
|
+
};
|
|
172
|
+
const first = chronological[0];
|
|
173
|
+
const last = chronological[chronological.length - 1];
|
|
174
|
+
const platforms = new Set();
|
|
175
|
+
for (const r of chronological) {
|
|
176
|
+
for (const k of Object.keys((r.perPlatform ?? {})))
|
|
177
|
+
platforms.add(k);
|
|
178
|
+
}
|
|
179
|
+
const moves = [];
|
|
180
|
+
for (const platform of platforms) {
|
|
181
|
+
const from = foundIn(first, platform);
|
|
182
|
+
const to = foundIn(last, platform);
|
|
183
|
+
if (from === null || to === null || from === to)
|
|
184
|
+
continue;
|
|
185
|
+
moves.push({ platform, from, to, delta: to - from });
|
|
186
|
+
}
|
|
187
|
+
if (!moves.length) {
|
|
188
|
+
return ("No network's count differs between the earliest and latest sweep in this window, so " +
|
|
189
|
+
"whatever moved, moved inside it rather than across it — look at the points themselves.");
|
|
190
|
+
}
|
|
191
|
+
moves.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
|
192
|
+
const said = moves
|
|
193
|
+
.slice(0, 4)
|
|
194
|
+
.map((m) => `${m.platform} ${m.delta > 0 ? "rose" : "fell"} from ${m.from} to ${m.to} ` +
|
|
195
|
+
`(${m.delta > 0 ? "+" : ""}${m.delta})`)
|
|
196
|
+
.join(", ");
|
|
197
|
+
return (`Earliest to latest sweep in this window: ${said}. Those directions are computed here on ` +
|
|
198
|
+
"purpose — `runs` is newest-first, so subtracting the ends by hand is where a real reading " +
|
|
199
|
+
"of this went backwards. Use these rather than re-deriving them.");
|
|
200
|
+
}
|
|
201
|
+
function trendGuidance(sc, runs) {
|
|
202
|
+
const n = runs.length;
|
|
203
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
204
|
+
const term = typeof sc.term === "string" && sc.term ? `"${sc.term}"` : "this watch";
|
|
205
|
+
const kind = sc.kind === "competitor" ? "competitor" : "mentions";
|
|
206
|
+
const lines = [];
|
|
207
|
+
if (!n) {
|
|
208
|
+
lines.push(`No runs recorded for ${term} in that window. A watch records a point every time it ` +
|
|
209
|
+
"sweeps, so this means it has not run yet rather than that nobody has said anything — " +
|
|
210
|
+
"say which, and check `nextRun` on list_brand_watches. Nothing was charged.");
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
}
|
|
213
|
+
const windowDays = num(sc.windowDays);
|
|
214
|
+
lines.push(`${n} run${n === 1 ? "" : "s"} of ${term}` +
|
|
215
|
+
(windowDays ? ` in the last ${windowDays} days` : "") +
|
|
216
|
+
`, newest first. Each point is one sweep: \`found\` is everything it saw, \`reported\` is ` +
|
|
217
|
+
"the subset that was new, and `perPlatform` splits both by network.");
|
|
218
|
+
if (n < 3) {
|
|
219
|
+
lines.push("", `Two points are not a trend, and ${n} ${n === 1 ? "is" : "are"} what you have. Report what ` +
|
|
220
|
+
"the runs found and say the series is too short to call a direction — the difference " +
|
|
221
|
+
"between two sweeps of scraped data is mostly the scrape.");
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
lines.push("", "Read it yourself: the shape of `found` over time is the volume question, and `perPlatform` " +
|
|
225
|
+
"is the one underneath it — a total that holds steady while one network doubles and " +
|
|
226
|
+
"another empties is the finding, and the total hides it.", "",
|
|
227
|
+
// Stated rather than left to be derived, because deriving it means
|
|
228
|
+
// knowing which end of the array is which — and `runs` is newest-first.
|
|
229
|
+
// Reading it by hand got the direction exactly backwards on the first
|
|
230
|
+
// attempt ("TikTok fell from 5 to 12"), which is a mistake a model makes
|
|
231
|
+
// just as easily and reports with the same confidence.
|
|
232
|
+
perPlatformDirection(runs), "", "`found` and `reported` are different questions and usually move differently. Flat `found` " +
|
|
233
|
+
"with falling `reported` is a conversation that has stopped moving rather than stopped. A " +
|
|
234
|
+
"spike in `found` that is almost all already-seen is one post being re-surfaced by a " +
|
|
235
|
+
"platform, not new interest.");
|
|
236
|
+
}
|
|
237
|
+
if (kind === "competitor") {
|
|
238
|
+
const newest = num(sc.medianViews?.newest);
|
|
239
|
+
const oldest = num(sc.medianViews?.oldest);
|
|
240
|
+
lines.push("", "This is a competitor watch, so each point carries `medianViews` — the baseline that run " +
|
|
241
|
+
"measured. That is the second axis: a creator can beat their own median every week while " +
|
|
242
|
+
"the median itself is falling, which is a shrinking account having good weeks." +
|
|
243
|
+
(newest !== null && oldest !== null
|
|
244
|
+
? ` Here it went from ${oldest.toLocaleString("en-US")} to ${newest.toLocaleString("en-US")}.`
|
|
245
|
+
: ""));
|
|
246
|
+
}
|
|
247
|
+
const retained = num(sc.retainedDays);
|
|
248
|
+
const created = typeof sc.watchCreatedAt === "string" ? sc.watchCreatedAt : null;
|
|
249
|
+
lines.push("", "Do not read the left edge as the start of anything." +
|
|
250
|
+
(retained ? ` Runs are kept for ${retained} days.` : "") +
|
|
251
|
+
(created ? ` This watch was created ${created.slice(0, 10)}.` : "") +
|
|
252
|
+
" A series that starts flat starts where the record does, which is not the same as a " +
|
|
253
|
+
"period when nobody was talking.");
|
|
254
|
+
const recurring = Array.isArray(sc.recurring) ? sc.recurring : [];
|
|
255
|
+
if (recurring.length) {
|
|
256
|
+
lines.push("", `\`recurring\` lists the ${recurring.length} mention${recurring.length === 1 ? "" : "s"} more ` +
|
|
257
|
+
"than one run has seen, with how many runs saw each. These are fingerprints, not text: " +
|
|
258
|
+
"the mentions themselves are other people's writing and are deliberately not stored. A " +
|
|
259
|
+
"high `timesSeen` is a post that keeps resurfacing — worth naming as one thing rather " +
|
|
260
|
+
"than as repeated new interest.");
|
|
261
|
+
}
|
|
262
|
+
lines.push("", "This is free because the sweeps were already billed. Nothing here needs a further call to " +
|
|
263
|
+
"interpret it — read the numbers and say what changed, including that nothing did, if that " +
|
|
264
|
+
"is what they show.");
|
|
265
|
+
return lines.join("\n");
|
|
266
|
+
}
|
|
139
267
|
function toResult(proxy) {
|
|
140
268
|
const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
|
|
141
269
|
return {
|
|
@@ -274,6 +402,70 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
274
402
|
return failed("list_brand_watches failed", err);
|
|
275
403
|
}
|
|
276
404
|
});
|
|
405
|
+
server.registerTool("mention_trend", {
|
|
406
|
+
title: "Mention Trend",
|
|
407
|
+
_meta: viewMeta("mention_trend"),
|
|
408
|
+
description: "What a watch has SEEN OVER TIME, which no other tool here can answer — every read on " +
|
|
409
|
+
"this surface answers about now, and until the backend started keeping run history each " +
|
|
410
|
+
"sweep threw its numbers away. Takes `watchId` (or `term`) and an optional `days`. " +
|
|
411
|
+
"Returns one point per run: when it ran, how many mentions it found, how many were new, " +
|
|
412
|
+
"and both counts per network — so \"which network is growing\" and \"did it go quiet, or " +
|
|
413
|
+
"did we stop looking\" are separable. On a competitor watch each point also carries the " +
|
|
414
|
+
"median that run measured, which is what turns \"they beat their own median\" into \"and " +
|
|
415
|
+
"that median has doubled\". Also returns the mentions more than one run has seen. " +
|
|
416
|
+
"FREE: the sweeps were billed when they ran, and charging again to read what was already " +
|
|
417
|
+
"paid for is the thing this fixes. " +
|
|
418
|
+
"Read the series yourself and say what changed — this returns numbers, not a verdict.",
|
|
419
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
420
|
+
inputSchema: z
|
|
421
|
+
.object({
|
|
422
|
+
watchId: z.string().optional().describe("The watch id from list_brand_watches."),
|
|
423
|
+
term: z
|
|
424
|
+
.string()
|
|
425
|
+
.optional()
|
|
426
|
+
.describe("The watched term, if you do not have the id. One of the two is required."),
|
|
427
|
+
days: z
|
|
428
|
+
.number()
|
|
429
|
+
.int()
|
|
430
|
+
.optional()
|
|
431
|
+
.describe("Window in days (default 90). Clamped to how long the backend keeps runs, which " +
|
|
432
|
+
"comes back as `retainedDays` — asking for more silently returns less, and a " +
|
|
433
|
+
"flat start would read as silence rather than as the edge of the record."),
|
|
434
|
+
})
|
|
435
|
+
.strict(),
|
|
436
|
+
outputSchema: OUTPUT_SCHEMAS.mention_trend,
|
|
437
|
+
}, async (args, extra) => {
|
|
438
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
439
|
+
if (!args.watchId && !args.term?.trim()) {
|
|
440
|
+
return {
|
|
441
|
+
content: [
|
|
442
|
+
{
|
|
443
|
+
type: "text",
|
|
444
|
+
text: "This needs a watch to read: pass `watchId` (from list_brand_watches) or the " +
|
|
445
|
+
"`term` a watch was created for. Nothing was fetched and nothing was charged.",
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
structuredContent: { available: false, billable: false, mcpCredits: { cost: 0 } },
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const proxy = await client.callTool("brand_watch_history", { ...args });
|
|
453
|
+
const result = toResult(proxy);
|
|
454
|
+
const sc = (result.structuredContent ?? {});
|
|
455
|
+
const runs = Array.isArray(sc.runs) ? sc.runs : [];
|
|
456
|
+
// Guidance in the text block, evidence in the structured one — the
|
|
457
|
+
// pattern every reading tool here uses. What it has to prevent is a
|
|
458
|
+
// model reading two points as a trend, and reading the start of the
|
|
459
|
+
// retention window as the moment a conversation began.
|
|
460
|
+
return {
|
|
461
|
+
...result,
|
|
462
|
+
content: [{ type: "text", text: trendGuidance(sc, runs) }],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
return failed("mention_trend failed", err);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
277
469
|
server.registerTool("stop_brand_watch", {
|
|
278
470
|
title: "Stop Brand Watch",
|
|
279
471
|
description: "Stop a scheduled brand-monitoring watch, by watchId or by term. Takes effect immediately: " +
|
|
@@ -299,6 +299,10 @@ export const LOADING_PLANS = {
|
|
|
299
299
|
show_variants: { label: "Laying out the variants", kind: "list", n: 4, steps: [], free: true },
|
|
300
300
|
show_repurposed_post: { label: "Laying out the rewrites", kind: "text", n: 1, steps: [], free: true },
|
|
301
301
|
show_collab_shortlist: { label: "Drawing the shortlist", kind: "list", n: 5, steps: [], free: true },
|
|
302
|
+
show_trend: { label: "Drawing the trend", kind: "text", n: 1, steps: [], free: true },
|
|
303
|
+
// Reads stored aggregates the sweeps already paid for. Free, and no
|
|
304
|
+
// upstream step to wait on — the wait is a database read.
|
|
305
|
+
mention_trend: { label: "Reading the run history", kind: "text", n: 1, steps: [], free: true },
|
|
302
306
|
prepare_handoff: { label: "Packaging the handoff", kind: "list", n: 4, steps: [], free: true },
|
|
303
307
|
// ─── Own-account reads: nooticr's own stored rows ───
|
|
304
308
|
get_scheduled_posts: { label: "Reading your pipeline", kind: "list", n: 5, steps: [], free: true },
|
|
@@ -842,6 +842,55 @@ export const OUTPUT_SCHEMAS = {
|
|
|
842
842
|
firstRun: scalar(),
|
|
843
843
|
message: scalar(),
|
|
844
844
|
}),
|
|
845
|
+
/**
|
|
846
|
+
* A run series. `runs` is newest first, and every derived number the caller
|
|
847
|
+
* might want is left underived on purpose — the model reads the series.
|
|
848
|
+
*/
|
|
849
|
+
show_trend: open({
|
|
850
|
+
points: listOf(anyObject()),
|
|
851
|
+
term: scalar(),
|
|
852
|
+
metric: scalar(),
|
|
853
|
+
verdict: scalar(),
|
|
854
|
+
// Both caveats are fields rather than prose, so the view can draw them
|
|
855
|
+
// and cannot quietly omit them.
|
|
856
|
+
tooShort: scalar().describe("Too few points to call a direction."),
|
|
857
|
+
edgeIsRecordStart: scalar().describe("The left edge is the record's start, not the conversation's."),
|
|
858
|
+
trend: scalar().describe("The discriminator the view keys on."),
|
|
859
|
+
}),
|
|
860
|
+
mention_trend: open({
|
|
861
|
+
runs: listOf(open({
|
|
862
|
+
ranAt: scalar(),
|
|
863
|
+
found: scalar().describe("Everything that sweep saw."),
|
|
864
|
+
reported: scalar().describe("The subset that was new — a different question from found."),
|
|
865
|
+
perPlatform: anyObject().nullish().describe('{"tiktok":{"found":12,"reported":3},...}'),
|
|
866
|
+
medianViews: scalar().describe("Competitor watches only: the baseline that run measured."),
|
|
867
|
+
postsScored: scalar(),
|
|
868
|
+
costCredits: scalar(),
|
|
869
|
+
})).optional(),
|
|
870
|
+
watchId: scalar(),
|
|
871
|
+
kind: scalar(),
|
|
872
|
+
term: scalar(),
|
|
873
|
+
competitorHandle: scalar(),
|
|
874
|
+
platforms: listOf(z.string()),
|
|
875
|
+
windowDays: scalar(),
|
|
876
|
+
// Two numbers a caller cannot derive and will otherwise assume: how long
|
|
877
|
+
// anything is kept, and how long the watch has existed. A flat left edge
|
|
878
|
+
// is one of those, not a quiet period.
|
|
879
|
+
retainedDays: scalar(),
|
|
880
|
+
watchCreatedAt: scalar(),
|
|
881
|
+
runCount: scalar(),
|
|
882
|
+
found: anyObject().nullish().describe("newest and oldest in the window, so direction needs no array maths."),
|
|
883
|
+
medianViews: anyObject().nullish(),
|
|
884
|
+
recurring: listOf(open({
|
|
885
|
+
mentionKey: scalar().describe("A fingerprint. The text is deliberately not stored."),
|
|
886
|
+
timesSeen: scalar(),
|
|
887
|
+
firstReportedAt: scalar(),
|
|
888
|
+
lastSeenAt: scalar(),
|
|
889
|
+
})),
|
|
890
|
+
available: scalar(),
|
|
891
|
+
billable: scalar(),
|
|
892
|
+
mcpCredits,
|
|
893
|
+
}),
|
|
845
894
|
list_brand_watches: open({
|
|
846
895
|
watches: listOf(open({
|
|
847
896
|
watchId: scalar(),
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -228,6 +228,18 @@ export const TOOL_DEFINITIONS = [
|
|
|
228
228
|
description: "Every scheduled brand-monitoring watch this user has: term, networks, cadence, cost per run, credits spent so far, runs made, next run due, and whether it is stopped and why. Read before creating a watch — a second watch on the same term is a second recurring charge for the same answer. No cost to call.",
|
|
229
229
|
inputSchema: z.object({}).strict(),
|
|
230
230
|
},
|
|
231
|
+
{
|
|
232
|
+
name: "mention_trend",
|
|
233
|
+
title: "Mention Trend",
|
|
234
|
+
description: "What a watch has SEEN OVER TIME, which no other tool here can answer — every read on this surface answers about now, and until the backend started keeping run history each sweep threw its numbers away. Takes `watchId` (or `term`) and an optional `days`. Returns one point per run: when it ran, how many mentions it found, how many were new, and both counts per network. On a competitor watch each point also carries the median that run measured. Also returns the mentions more than one run has seen. FREE: the sweeps were billed when they ran. Read the series yourself and say what changed.",
|
|
235
|
+
inputSchema: z.object({ watchId: z.string().optional(), term: z.string().optional(), days: z.number().int().optional() }).strict(),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "show_trend",
|
|
239
|
+
title: "Show Trend",
|
|
240
|
+
description: "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what you pass it: the series as a chart, per-network lines where you supply them, and your own read of what changed. Pass `tooShort` when the series has too few points to call a direction, and `edgeIsRecordStart` when the left edge is where the record begins rather than where the conversation did. Call this after you have decided what the numbers mean.",
|
|
241
|
+
inputSchema: z.object({ points: z.array(z.object({}).passthrough()).min(1).max(400), term: z.string().optional(), metric: z.string().optional(), verdict: z.string().optional(), tooShort: z.boolean().optional(), edgeIsRecordStart: z.boolean().optional() }).strict(),
|
|
242
|
+
},
|
|
231
243
|
{
|
|
232
244
|
name: "stop_brand_watch",
|
|
233
245
|
title: "Stop Brand Watch",
|
package/dist/shared/tools.js
CHANGED
|
@@ -25,7 +25,7 @@ import { loadingPlansJson } from "./loading-plans.js";
|
|
|
25
25
|
import { registerHandoff } from "./handoff.js";
|
|
26
26
|
import { registerCollabTools } from "./collab.js";
|
|
27
27
|
/** Current MCP server version — bumped on every deploy for traceability. */
|
|
28
|
-
export const MCP_SERVER_VERSION = "1.26.
|
|
28
|
+
export const MCP_SERVER_VERSION = "1.26.27";
|
|
29
29
|
/** MCP Apps extension identifier */
|
|
30
30
|
const UI_EXTENSION = "io.modelcontextprotocol/ui";
|
|
31
31
|
/** MIME type for MCP Apps HTML resources */
|
|
@@ -507,6 +507,9 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
507
507
|
// state tools have nothing to show and stay view-less, like nooticr_login.
|
|
508
508
|
"catch_up_watchlist",
|
|
509
509
|
"search_mentions",
|
|
510
|
+
// The series a watch keeps, and the free view for the read of it.
|
|
511
|
+
"mention_trend",
|
|
512
|
+
"show_trend",
|
|
510
513
|
"show_comment_review",
|
|
511
514
|
// Close the loop the evidence-only tools open: your own analysis/hooks/
|
|
512
515
|
// variants/repurposing/comparison, drawn — same shape as
|
|
@@ -1353,6 +1356,68 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1353
1356
|
// ui-template.ts already renders (show_comparison → the comparison
|
|
1354
1357
|
// scoreboard, show_analysis → analysisCard) or a new one added alongside
|
|
1355
1358
|
// it (show_hooks, show_variants, show_repurposed_post).
|
|
1359
|
+
server.registerTool("show_trend", {
|
|
1360
|
+
title: "Show Trend",
|
|
1361
|
+
description: "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what " +
|
|
1362
|
+
"you pass it: the series as a chart, per-network lines where you supply them, and your own " +
|
|
1363
|
+
"read of what changed. Pass `tooShort` when the series has too few points to call a " +
|
|
1364
|
+
"direction, and `edgeIsRecordStart` when the left edge is where the record begins rather " +
|
|
1365
|
+
"than where the conversation did — a chart that hides either is the one mistake this view " +
|
|
1366
|
+
"can make on your behalf. Call this after you have decided what the numbers mean.",
|
|
1367
|
+
_meta: {
|
|
1368
|
+
ui: { resourceUri: uiResource("show_trend") },
|
|
1369
|
+
"ui/resourceUri": uiResource("show_trend"),
|
|
1370
|
+
"openai/outputTemplate": appsSdkResource("show_trend"),
|
|
1371
|
+
},
|
|
1372
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1373
|
+
outputSchema: OUTPUT_SCHEMAS.show_trend,
|
|
1374
|
+
inputSchema: z
|
|
1375
|
+
.object({
|
|
1376
|
+
points: z
|
|
1377
|
+
.array(anyObject())
|
|
1378
|
+
.min(1)
|
|
1379
|
+
.max(400)
|
|
1380
|
+
.describe("The series, oldest first or newest first as you like — the same shape mention_trend " +
|
|
1381
|
+
"returned (ranAt, found, reported, perPlatform, medianViews)."),
|
|
1382
|
+
term: z.string().optional().describe("What is being watched."),
|
|
1383
|
+
metric: z
|
|
1384
|
+
.string()
|
|
1385
|
+
.optional()
|
|
1386
|
+
.describe('Which number the chart plots: "found" (default), "reported" or "medianViews".'),
|
|
1387
|
+
verdict: z.string().optional().describe("Your read of what changed, in a sentence or two."),
|
|
1388
|
+
tooShort: z
|
|
1389
|
+
.boolean()
|
|
1390
|
+
.optional()
|
|
1391
|
+
.describe("True when there are too few points to call a direction. Drawn as a stated caveat rather " +
|
|
1392
|
+
"than a trend line, because two points of scraped data are mostly the scrape."),
|
|
1393
|
+
edgeIsRecordStart: z
|
|
1394
|
+
.boolean()
|
|
1395
|
+
.optional()
|
|
1396
|
+
.describe("True when the earliest point is the start of the kept record or the watch's own age, " +
|
|
1397
|
+
"not the start of the conversation. Marks the left edge so a flat start is not read as " +
|
|
1398
|
+
"silence."),
|
|
1399
|
+
})
|
|
1400
|
+
.strict(),
|
|
1401
|
+
}, async (args) => {
|
|
1402
|
+
return {
|
|
1403
|
+
content: [
|
|
1404
|
+
{
|
|
1405
|
+
type: "text",
|
|
1406
|
+
text: `Showing a ${args.points.length}-point trend${args.term ? ` for "${args.term}"` : ""}.` +
|
|
1407
|
+
(args.verdict ? ` ${args.verdict}` : ""),
|
|
1408
|
+
},
|
|
1409
|
+
],
|
|
1410
|
+
structuredContent: {
|
|
1411
|
+
points: args.points,
|
|
1412
|
+
term: args.term ?? null,
|
|
1413
|
+
metric: args.metric ?? "found",
|
|
1414
|
+
verdict: args.verdict ?? null,
|
|
1415
|
+
tooShort: args.tooShort ?? false,
|
|
1416
|
+
edgeIsRecordStart: args.edgeIsRecordStart ?? false,
|
|
1417
|
+
trend: true,
|
|
1418
|
+
},
|
|
1419
|
+
};
|
|
1420
|
+
});
|
|
1356
1421
|
server.registerTool("show_comparison", {
|
|
1357
1422
|
title: "Show Comparison",
|
|
1358
1423
|
description: "Display a comparison you wrote after compare_posts fetched the first post and you fetched " +
|
|
@@ -3689,6 +3689,46 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
3689
3689
|
+"</div></div>";
|
|
3690
3690
|
return;}
|
|
3691
3691
|
|
|
3692
|
+
// A run series. Drawn as bars rather than a line: the points are sweeps on
|
|
3693
|
+
// a cadence, not a continuous signal, and a line between two sweeps
|
|
3694
|
+
// asserts the shape of something nobody measured. Both caveats the tool
|
|
3695
|
+
// computes are drawn — a chart that hides "too few points" or "this is
|
|
3696
|
+
// where the record starts, not where the conversation did" is the view
|
|
3697
|
+
// making a claim the tool refused to (issue #28).
|
|
3698
|
+
if(Array.isArray(d.points)&&(d.trend||d.tool==="mention_trend")){
|
|
3699
|
+
var metric=String(d.metric||"found");
|
|
3700
|
+
var pts=d.points.slice().sort(function(a,b){return String(a.ranAt||"")<String(b.ranAt||"")?-1:1;});
|
|
3701
|
+
var vals=pts.map(function(p){var v=p[metric];return typeof v==="number"?v:0;});
|
|
3702
|
+
var top=Math.max.apply(null,vals.concat([1]));
|
|
3703
|
+
var bars=pts.map(function(p,i){
|
|
3704
|
+
var v=vals[i];
|
|
3705
|
+
var h=Math.max(2,Math.round((v/top)*90));
|
|
3706
|
+
var when=String(p.ranAt||"").slice(0,10);
|
|
3707
|
+
var label=when+" — "+fmtNum(v)+" "+metric+(p.reported!=null?" ("+fmtNum(p.reported)+" new)":"");
|
|
3708
|
+
return '<div style="flex:1;min-width:3px;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:3px" title="'+esc(label)+'">'
|
|
3709
|
+
+'<div style="width:100%;max-width:26px;height:'+h+'px;background:var(--brand);border-radius:3px 3px 0 0;opacity:'+(i===pts.length-1?"1":".62")+'"></div>'
|
|
3710
|
+
+"</div>";
|
|
3711
|
+
}).join("");
|
|
3712
|
+
var first=pts.length?String(pts[0].ranAt||"").slice(0,10):"";
|
|
3713
|
+
var last=pts.length?String(pts[pts.length-1].ranAt||"").slice(0,10):"";
|
|
3714
|
+
app.innerHTML='<div class="card card-wide fade-in"><div class="card-body">'
|
|
3715
|
+
+'<div style="font-size:16px;font-weight:700">'+(d.term?esc(String(d.term)):"Trend")+"</div>"
|
|
3716
|
+
+'<div class="faint" style="font-size:11.5px;color:var(--muted);margin:2px 0 12px">'
|
|
3717
|
+
+pts.length+" run"+(pts.length===1?"":"s")+" · "+esc(metric)+" per sweep</div>"
|
|
3718
|
+
+(d.tooShort
|
|
3719
|
+
?'<div style="font-size:12.5px;color:var(--warn);font-weight:600;margin-bottom:8px">Too few points to call a direction</div>'
|
|
3720
|
+
:"")
|
|
3721
|
+
+'<div style="display:flex;align-items:flex-end;gap:2px;height:100px;border-bottom:1px solid var(--border)">'+bars+"</div>"
|
|
3722
|
+
+'<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:5px">'
|
|
3723
|
+
+"<span>"+esc(first)+(d.edgeIsRecordStart?" · record starts here":"")+"</span><span>"+esc(last)+"</span></div>"
|
|
3724
|
+
+(d.verdict?'<div style="font-size:13.5px;line-height:1.6;margin-top:12px">'+esc(String(d.verdict))+"</div>":"")
|
|
3725
|
+
+'<div class="faint" style="font-size:11.5px;color:var(--muted);margin-top:10px;line-height:1.6">'
|
|
3726
|
+
+"Each bar is one sweep, not a day"
|
|
3727
|
+
+(d.edgeIsRecordStart?" — and the earliest bar is where the kept record begins, not where the conversation did":"")
|
|
3728
|
+
+". A flat run of bars is a conversation that has stopped moving, which is not the same as one that has stopped."
|
|
3729
|
+
+"</div></div></div>";
|
|
3730
|
+
return;}
|
|
3731
|
+
|
|
3692
3732
|
// Trending hashtags. Two sources with different evidence behind them: the
|
|
3693
3733
|
// TikTok trend board, which measures direction over time, and a count
|
|
3694
3734
|
// across one niche sweep, which cannot. The header names which, and a
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nooticr/mcp",
|
|
3
3
|
"mcpName": "com.nooticr/mcp",
|
|
4
|
-
"version": "1.26.
|
|
4
|
+
"version": "1.26.27",
|
|
5
5
|
"description": "MCP server for nooticr - fetch, discover and understand TikTok/Instagram/YouTube/X/Reddit/LinkedIn/Weibo posts with AI (media metadata, niche discovery, brand monitoring, hook & viral analysis)",
|
|
6
6
|
"homepage": "https://mcp.nooticr.com",
|
|
7
7
|
"repository": {
|