@nooticr/mcp 1.26.47 → 1.26.49
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 +3 -2
- package/dist/index.js +9 -1
- package/dist/shared/api-key.js +38 -0
- package/dist/shared/brand-watch.js +208 -15
- package/dist/shared/loading-plans.js +1 -0
- package/dist/shared/nooticr.js +5 -2
- package/dist/shared/output-schemas.js +27 -5
- package/dist/shared/tools-def.js +8 -2
- package/dist/shared/tools.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ npx @nooticr/mcp login # one-time sign-in (Google)
|
|
|
52
52
|
|
|
53
53
|
## Tools
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
88 tools, grouped by what you are trying to do. Prices are in nooticr credits and
|
|
56
56
|
match what the server actually charges.
|
|
57
57
|
|
|
58
58
|
The job tools under **Answer a question you actually have** are not
|
|
@@ -101,9 +101,10 @@ them caps that fan-out with an argument.
|
|
|
101
101
|
| `unwatch_creator` | free | Drop a creator from the watchlist. |
|
|
102
102
|
| `list_watchlist` | free | Who you are watching: handle, platform, note, and when you last caught up on each. |
|
|
103
103
|
| `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". |
|
|
104
|
-
| `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. |
|
|
104
|
+
| `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; `kind: "report"` runs `job: {tool, args}` — `niche_report` (mailed as written every run) or `discover_social_posts` (only unseen posts) — on the schedule; `kind: "portfolio"` sweeps up to 25 `terms` (your brand, products, competitors) across the same networks under one budget and mails one digest with each term's share of voice. 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, the job tool's own price for a report (3 for `niche_report`), and a mentions sweep per term for a portfolio — whose budget is split evenly and trims networks, never terms, and which refunds any term whose sweep fails. |
|
|
105
105
|
| `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. |
|
|
106
106
|
| `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. |
|
|
107
|
+
| `update_watch_portfolio` | free | Add or remove a portfolio watch's terms by `watchId`. No new confirmation, because the per-run budget you agreed to does not move — more terms split it more ways, and the reply names any network a run no longer reaches. |
|
|
107
108
|
| `stop_brand_watch` | free | Stop a watch by `watchId` or `term`. Immediate — the next run does not happen and nothing more is charged. |
|
|
108
109
|
| `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. |
|
|
109
110
|
| `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. |
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
15
15
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
16
16
|
import { getBaseUrl, getPublicUrl, getPort, getCredentialsFile, getTransportMode, DEFAULT_PORT, } from "./config.js";
|
|
17
17
|
import { AuthManager, NooticrAuthError, createApiKeyTokenProvider, createHttpTokenProvider, createManagementTokenProvider, createStdioTokenProvider, } from "./auth.js";
|
|
18
|
-
import { looksLikeApiKey } from "./shared/api-key.js";
|
|
18
|
+
import { forwardedHeaders, forwardedSession, looksLikeApiKey } from "./shared/api-key.js";
|
|
19
19
|
import { NooticrClient, NooticrError } from "./nooticr.js";
|
|
20
20
|
import { OAuthManager, SCOPES } from "./oauth.js";
|
|
21
21
|
import { createMcpServer } from "./shared/tools.js";
|
|
@@ -95,6 +95,9 @@ function validMcpToken(token, oauth) {
|
|
|
95
95
|
return true;
|
|
96
96
|
if (looksLikeApiKey(token))
|
|
97
97
|
return true;
|
|
98
|
+
// Same reasoning as a key: the backend verifies the session on every call.
|
|
99
|
+
if (forwardedSession(token))
|
|
100
|
+
return true;
|
|
98
101
|
const envToken = process.env.NOOTICR_ACCESS_TOKEN;
|
|
99
102
|
return typeof envToken === "string" && envToken.length > 0 && token === envToken;
|
|
100
103
|
}
|
|
@@ -242,6 +245,11 @@ export async function runHttp(port, publicUrl) {
|
|
|
242
245
|
if (looksLikeApiKey(bearer)) {
|
|
243
246
|
return new NooticrClient(baseUrl, createApiKeyTokenProvider(bearer));
|
|
244
247
|
}
|
|
248
|
+
// nooticr-server's chat, calling as the user signed in to it.
|
|
249
|
+
const forwarded = forwardedSession(bearer);
|
|
250
|
+
if (forwarded) {
|
|
251
|
+
return new NooticrClient(baseUrl, createApiKeyTokenProvider(forwarded.token), undefined, forwardedHeaders(forwarded));
|
|
252
|
+
}
|
|
245
253
|
const session = bearer
|
|
246
254
|
? oauth.verifyToken(bearer)
|
|
247
255
|
: undefined;
|
package/dist/shared/api-key.js
CHANGED
|
@@ -18,3 +18,41 @@ export const API_KEY_PREFIX = "nk_";
|
|
|
18
18
|
export function looksLikeApiKey(token) {
|
|
19
19
|
return typeof token === "string" && token.startsWith(API_KEY_PREFIX);
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* A nooticr session forwarded by nooticr-server's own agent — the dashboard's
|
|
23
|
+
* chat, calling these tools on behalf of the user who is signed in there.
|
|
24
|
+
*
|
|
25
|
+
* The chat used to call a second, Rust copy of 28 of these tools, so it had
|
|
26
|
+
* none of the others and none of their guidance. It now calls this server,
|
|
27
|
+
* and needs a way to say "as this user" that is not an OAuth grant (there is
|
|
28
|
+
* no browser in the loop) and not an API key (the user never minted one).
|
|
29
|
+
*
|
|
30
|
+
* Shape: `nooticr-session.<conversation id or ->.<nooticr access token>`. The
|
|
31
|
+
* token is the user's own, forwarded to the backend exactly as a key is, and
|
|
32
|
+
* the backend checks it on every call. The conversation rides along so the
|
|
33
|
+
* backend's run ledger files each call under the chat thread that made it,
|
|
34
|
+
* rather than as an anonymous connector call. The prefix keeps it from ever
|
|
35
|
+
* being read as an OAuth token this process issued, which a bare JWT would be.
|
|
36
|
+
*/
|
|
37
|
+
export const SESSION_TOKEN_PREFIX = "nooticr-session.";
|
|
38
|
+
/** The session inside a forwarded bearer, or undefined for anything else. */
|
|
39
|
+
export function forwardedSession(bearer) {
|
|
40
|
+
if (typeof bearer !== "string" || !bearer.startsWith(SESSION_TOKEN_PREFIX))
|
|
41
|
+
return undefined;
|
|
42
|
+
const rest = bearer.slice(SESSION_TOKEN_PREFIX.length);
|
|
43
|
+
const dot = rest.indexOf(".");
|
|
44
|
+
if (dot < 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
const conversation = rest.slice(0, dot);
|
|
47
|
+
const token = rest.slice(dot + 1).trim();
|
|
48
|
+
if (!token)
|
|
49
|
+
return undefined;
|
|
50
|
+
return /^[0-9a-f-]{36}$/i.test(conversation) ? { token, conversationId: conversation } : { token };
|
|
51
|
+
}
|
|
52
|
+
/** Headers that attribute a forwarded call to the chat thread that made it. */
|
|
53
|
+
export function forwardedHeaders(session) {
|
|
54
|
+
return {
|
|
55
|
+
"x-nooticr-surface": "chat",
|
|
56
|
+
...(session.conversationId ? { "x-nooticr-conversation": session.conversationId } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -34,7 +34,15 @@ const CADENCE_RATE = {
|
|
|
34
34
|
weekly: { runs: 1, per: "week" },
|
|
35
35
|
};
|
|
36
36
|
/** Mirrors `crate::brand_watch::WatchKind` (nooticr-server, brand_watch.rs). */
|
|
37
|
-
const KINDS = ["mentions", "competitor"];
|
|
37
|
+
const KINDS = ["mentions", "competitor", "report", "portfolio"];
|
|
38
|
+
/**
|
|
39
|
+
* Mirrors `SCHEDULABLE_JOBS` in nooticr-server's brand_watch.rs: the tools a
|
|
40
|
+
* report watch may run. Anything else is refused there too; listing them here
|
|
41
|
+
* is so the enum a host sees is the list that will actually be accepted.
|
|
42
|
+
*/
|
|
43
|
+
export const SCHEDULABLE_JOBS = ["niche_report", "discover_social_posts"];
|
|
44
|
+
/** Mirrors `MAX_PORTFOLIO_TERMS` (and the migration's check). */
|
|
45
|
+
const MAX_PORTFOLIO_TERMS = 25;
|
|
38
46
|
/**
|
|
39
47
|
* What one run of a competitor watch bills: one flat `get_user_posts` call,
|
|
40
48
|
* whatever the platform. Mirrors `COMPETITOR_SWEEP_TOOL`'s cost
|
|
@@ -46,6 +54,47 @@ const KINDS = ["mentions", "competitor"];
|
|
|
46
54
|
function competitorWatchCost() {
|
|
47
55
|
return BACKEND_CALL_CREDITS.get_user_posts;
|
|
48
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* What one run of a report watch bills: its job's own price, the same number
|
|
59
|
+
* `mcp_tool_cost_for` charges for that call by hand. A discovery job that
|
|
60
|
+
* opens posts for their comments pays one call per post opened.
|
|
61
|
+
*/
|
|
62
|
+
function reportWatchCost(job) {
|
|
63
|
+
if (!job)
|
|
64
|
+
return 0;
|
|
65
|
+
if (job.tool === "niche_report")
|
|
66
|
+
return NICHE_REPORT_CREDITS;
|
|
67
|
+
const base = BACKEND_CALL_CREDITS.discover_social_posts;
|
|
68
|
+
if (!job.args?.includeComments)
|
|
69
|
+
return base;
|
|
70
|
+
const opened = Math.min(10, Math.max(1, Math.trunc(job.args.openPosts ?? 5)));
|
|
71
|
+
return base + opened;
|
|
72
|
+
}
|
|
73
|
+
/** `niche_report`'s price in `mcp_tool_cost` — text-only AI, no video. */
|
|
74
|
+
const NICHE_REPORT_CREDITS = 3;
|
|
75
|
+
/** The terms a portfolio watch sweeps, as the server will read them. */
|
|
76
|
+
function portfolioTerms(terms) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const raw of terms ?? []) {
|
|
79
|
+
const t = raw.trim();
|
|
80
|
+
if (t && !out.some((o) => o.toLowerCase() === t.toLowerCase()))
|
|
81
|
+
out.push(t);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/** An upper bound on one run of any kind, before the budget ceiling. */
|
|
86
|
+
function sweepCost(args) {
|
|
87
|
+
switch (args.kind ?? "mentions") {
|
|
88
|
+
case "competitor":
|
|
89
|
+
return competitorWatchCost();
|
|
90
|
+
case "report":
|
|
91
|
+
return reportWatchCost(args.job);
|
|
92
|
+
case "portfolio":
|
|
93
|
+
return searchMentionsCost(args.platforms) * Math.max(1, portfolioTerms(args.terms).length);
|
|
94
|
+
default:
|
|
95
|
+
return searchMentionsCost(args.platforms);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
49
98
|
/**
|
|
50
99
|
* The human-facing half of the quote-then-confirm protocol.
|
|
51
100
|
*
|
|
@@ -70,7 +119,7 @@ async function gateRecurringCharge(server, args) {
|
|
|
70
119
|
// means a competitor watch's single 2-credit call always fits, so there is
|
|
71
120
|
// nothing to trim in practice — unlike a mentions sweep, which frequently
|
|
72
121
|
// does.
|
|
73
|
-
const sweep =
|
|
122
|
+
const sweep = sweepCost(args);
|
|
74
123
|
// An upper bound on the run, deliberately, rather than the exact figure.
|
|
75
124
|
// The server does not bill the budget: `plan_run` drops whole platforms
|
|
76
125
|
// until the rest fit under it, so a ceiling of 9 against 2-credit networks
|
|
@@ -81,10 +130,22 @@ async function gateRecurringCharge(server, args) {
|
|
|
81
130
|
const rate = CADENCE_RATE[args.cadence ?? "daily"];
|
|
82
131
|
const perPeriod = perRun * rate.runs;
|
|
83
132
|
const where = args.deliverTo ? ` Its digest goes to ${args.deliverTo}.` : "";
|
|
84
|
-
const
|
|
133
|
+
const every = (args.cadence ?? "daily").replace(/_/g, " ");
|
|
134
|
+
const terms = portfolioTerms(args.terms);
|
|
135
|
+
const label = kind === "competitor"
|
|
136
|
+
? `@${args.handle} on ${args.platform ?? "?"}`
|
|
137
|
+
: kind === "report"
|
|
138
|
+
? `"${args.job?.args?.niche ?? "?"}" with ${args.job?.tool ?? "?"}`
|
|
139
|
+
: kind === "portfolio"
|
|
140
|
+
? `${terms.length} term${terms.length === 1 ? "" : "s"} (${terms.join(", ")})`
|
|
141
|
+
: `"${args.term}"`;
|
|
85
142
|
const what = kind === "competitor"
|
|
86
|
-
? `email what beats their own median, ${
|
|
87
|
-
:
|
|
143
|
+
? `email what beats their own median, ${every}`
|
|
144
|
+
: kind === "report"
|
|
145
|
+
? `email the result, ${every}`
|
|
146
|
+
: kind === "portfolio"
|
|
147
|
+
? `email one digest with each term's share of voice, ${every}`
|
|
148
|
+
: `email what is new, ${every}`;
|
|
88
149
|
const decision = await confirmSpend(server.server, {
|
|
89
150
|
credits: perPeriod,
|
|
90
151
|
// Not a number worth weighing against a dialog — a standing charge. See
|
|
@@ -102,9 +163,12 @@ async function gateRecurringCharge(server, args) {
|
|
|
102
163
|
// Said plainly, because the first model to meet this read "cancelled" as
|
|
103
164
|
// a server fault and reported the feature broken — one retry away from
|
|
104
165
|
// treating a person's "no" as an obstacle to route around.
|
|
105
|
-
const
|
|
106
|
-
?
|
|
107
|
-
:
|
|
166
|
+
const oneOffTool = kind === "competitor"
|
|
167
|
+
? "get_user_posts call"
|
|
168
|
+
: kind === "report"
|
|
169
|
+
? `${args.job?.tool ?? "report"} call`
|
|
170
|
+
: "search_mentions sweep";
|
|
171
|
+
const oneOff = `A one-off ${oneOffTool} costs ${kind === "portfolio" ? searchMentionsCost(args.platforms) : perRun} credits${kind === "portfolio" ? " per term" : ""} and repeats never.`;
|
|
108
172
|
return declinedResult(perPeriod, `That watch`, `Nothing was created, and nothing is wrong: the person paying declined the recurring ` +
|
|
109
173
|
`charge, or could not be shown it. Calling again with the same token will reach the ` +
|
|
110
174
|
`same answer — ask them directly instead. ${oneOff}`);
|
|
@@ -199,6 +263,36 @@ function perPlatformDirection(runs) {
|
|
|
199
263
|
"purpose — `runs` is newest-first, so subtracting the ends by hand is where a real reading " +
|
|
200
264
|
"of this went backwards. Use these rather than re-deriving them.");
|
|
201
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* A portfolio's share of voice, newest run against oldest — computed here for
|
|
268
|
+
* the same reason `perPlatformDirection` is: `runs` is newest-first.
|
|
269
|
+
*/
|
|
270
|
+
function portfolioShare(runs) {
|
|
271
|
+
const at = (r) => String(r.ranAt ?? "");
|
|
272
|
+
const chronological = [...runs].sort((a, b) => (at(a) < at(b) ? -1 : 1));
|
|
273
|
+
const shareOf = (r) => (r?.perTerm ?? {});
|
|
274
|
+
const latest = shareOf(chronological[chronological.length - 1]);
|
|
275
|
+
const earliest = shareOf(chronological[0]);
|
|
276
|
+
const terms = Object.keys(latest);
|
|
277
|
+
if (!terms.length) {
|
|
278
|
+
return ("This is a portfolio watch, but no run in this window recorded per-term counts, so there " +
|
|
279
|
+
"is no share of voice to report yet.");
|
|
280
|
+
}
|
|
281
|
+
const said = terms
|
|
282
|
+
.map((t) => {
|
|
283
|
+
const now = latest[t];
|
|
284
|
+
if (now?.error)
|
|
285
|
+
return `${t}: not searched in the latest run (its sweep failed and was refunded)`;
|
|
286
|
+
const then = earliest[t];
|
|
287
|
+
const was = chronological.length > 1 && typeof then?.share === "number" ? `, from ${then.share}%` : "";
|
|
288
|
+
return `${t}: ${now?.share ?? 0}% of mentions found (${now?.found ?? 0})${was}`;
|
|
289
|
+
})
|
|
290
|
+
.join("; ");
|
|
291
|
+
return (`This is a portfolio watch, so each point also carries \`perTerm\` — every term's found and new ` +
|
|
292
|
+
`counts and its share of all mentions found that run. Latest run: ${said}. A share is only ` +
|
|
293
|
+
"comparable between terms because every term was searched on the same networks; a term " +
|
|
294
|
+
"added to the portfolio part-way through has no share before it was added, which is not zero.");
|
|
295
|
+
}
|
|
202
296
|
function trendGuidance(sc, runs) {
|
|
203
297
|
const n = runs.length;
|
|
204
298
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
@@ -252,6 +346,9 @@ function trendGuidance(sc, runs) {
|
|
|
252
346
|
(created ? ` This watch was created ${created.slice(0, 10)}.` : "") +
|
|
253
347
|
" A series that starts flat starts where the record does, which is not the same as a " +
|
|
254
348
|
"period when nobody was talking.");
|
|
349
|
+
if (sc.kind === "portfolio") {
|
|
350
|
+
lines.push("", portfolioShare(runs));
|
|
351
|
+
}
|
|
255
352
|
const recurring = Array.isArray(sc.recurring) ? sc.recurring : [];
|
|
256
353
|
if (recurring.length) {
|
|
257
354
|
lines.push("", `\`recurring\` lists the ${recurring.length} mention${recurring.length === 1 ? "" : "s"} more ` +
|
|
@@ -293,13 +390,21 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
293
390
|
server.registerTool("create_brand_watch", {
|
|
294
391
|
title: "Create Brand Watch",
|
|
295
392
|
description: "Run a sweep on a schedule and email the user what is new, instead of them remembering to " +
|
|
296
|
-
"ask.
|
|
393
|
+
"ask. Four kinds, chosen with kind: kind: \"mentions\" (the default — omit kind entirely for " +
|
|
297
394
|
"this one) runs a brand-mentions sweep for term across platforms; kind: \"competitor\" runs " +
|
|
298
395
|
"a get_user_posts sweep for one creator (handle + platform) and reports only the posts that " +
|
|
299
396
|
"beat that creator's own recent median — a raw view count would just measure follower size, " +
|
|
300
|
-
"so a run only mails what is unusually good for them
|
|
301
|
-
"
|
|
302
|
-
"
|
|
397
|
+
"so a run only mails what is unusually good for them; kind: \"report\" runs one tool on the " +
|
|
398
|
+
"schedule, given as job: {tool, args} — niche_report (the report is mailed as written every " +
|
|
399
|
+
"run) or discover_social_posts (only posts no earlier run mailed) — billed at that tool's " +
|
|
400
|
+
"own price; kind: \"portfolio\" sweeps several terms at once (a brand, its products and its " +
|
|
401
|
+
"competitors, up to 25) across the same platforms under one budget and mails one digest with " +
|
|
402
|
+
"each term's share of voice — the budget is split evenly across the terms and trims " +
|
|
403
|
+
"networks, never terms, and a term whose sweep fails is refunded. Add or remove a " +
|
|
404
|
+
"portfolio's terms later with update_watch_portfolio. term/platforms belong to a mentions " +
|
|
405
|
+
"watch; handle/platform belong to a competitor watch; job to a report; terms/platforms (and " +
|
|
406
|
+
"optionally term, as the portfolio's name) to a portfolio — pass what matches kind and " +
|
|
407
|
+
"leave the rest out. Two calls by design, because this starts a charge that recurs " +
|
|
303
408
|
"while nobody is watching: call it once with no confirmation to get back the cost per run, " +
|
|
304
409
|
"the cadence and what those multiply out to per day, put those numbers to the user in your " +
|
|
305
410
|
"reply, and only then call it again with confirm: true and the confirmationToken you were " +
|
|
@@ -311,7 +416,9 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
311
416
|
"between them and a standing charge. Each run bills exactly what the same call costs when a " +
|
|
312
417
|
"person asks for it themselves: for a mentions watch, 2 credits per network swept, 5 for " +
|
|
313
418
|
"Xiaohongshu; for a competitor watch, a flat 2 credits (one get_user_posts call), whatever " +
|
|
314
|
-
"the platform
|
|
419
|
+
"the platform; for a report, the job tool's own price (niche_report 3, discover_social_posts " +
|
|
420
|
+
"2, plus 1 per post opened for comments); for a portfolio, a mentions sweep per term. " +
|
|
421
|
+
"budgetCredits is a hard per-run ceiling enforced on the server, not a " +
|
|
315
422
|
"suggestion — a mentions sweep that would cost more is trimmed to the networks that fit, " +
|
|
316
423
|
"never widened. A run that turns up nothing new — or, for a competitor watch, nothing above " +
|
|
317
424
|
"median — sends no mail. cadence is hourly, every_6_hours, every_12_hours, daily or weekly, " +
|
|
@@ -322,8 +429,8 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
322
429
|
kind: z
|
|
323
430
|
.enum(KINDS)
|
|
324
431
|
.optional()
|
|
325
|
-
.describe("\"mentions\" (default) or \"
|
|
326
|
-
"below
|
|
432
|
+
.describe("\"mentions\" (default), \"competitor\", \"report\" or \"portfolio\". Selects which " +
|
|
433
|
+
"of the arguments below apply."),
|
|
327
434
|
term: z
|
|
328
435
|
.string()
|
|
329
436
|
.optional()
|
|
@@ -346,6 +453,39 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
346
453
|
.optional()
|
|
347
454
|
.describe("Required for kind: \"competitor\" — the single network that creator posts on, e.g. " +
|
|
348
455
|
"\"tiktok\". Not used for a mentions watch."),
|
|
456
|
+
job: z
|
|
457
|
+
.object({
|
|
458
|
+
tool: z
|
|
459
|
+
.enum(SCHEDULABLE_JOBS)
|
|
460
|
+
.describe("The tool to run on the schedule. Only these two can be scheduled."),
|
|
461
|
+
args: z
|
|
462
|
+
.object({
|
|
463
|
+
niche: z.string().describe("Niche or topic, e.g. \"home fitness\"."),
|
|
464
|
+
platform: z.string().optional().describe("Platform to read (the tool's own default when omitted)."),
|
|
465
|
+
count: z.number().int().optional().describe("niche_report only: posts to survey (default 20, max 40)."),
|
|
466
|
+
keywords: z.string().optional().describe("discover_social_posts only: extra keywords."),
|
|
467
|
+
limit: z.number().int().optional().describe("discover_social_posts only: max posts (default 6)."),
|
|
468
|
+
includeComments: z
|
|
469
|
+
.boolean()
|
|
470
|
+
.optional()
|
|
471
|
+
.describe("discover_social_posts only: also read comments — 1 extra credit per post opened, every run."),
|
|
472
|
+
openPosts: z.number().int().optional().describe("discover_social_posts only: posts to open for comments (default 5, max 10)."),
|
|
473
|
+
commentsPerPost: z.number().int().optional().describe("discover_social_posts only: comments per opened post."),
|
|
474
|
+
})
|
|
475
|
+
.strict()
|
|
476
|
+
.describe("The arguments the tool would take by hand. Pagination is not schedulable."),
|
|
477
|
+
})
|
|
478
|
+
.strict()
|
|
479
|
+
.optional()
|
|
480
|
+
.describe("Required for kind: \"report\" — the job each run performs. Not used for other kinds."),
|
|
481
|
+
terms: z
|
|
482
|
+
.array(z.string())
|
|
483
|
+
.min(1)
|
|
484
|
+
.max(MAX_PORTFOLIO_TERMS)
|
|
485
|
+
.optional()
|
|
486
|
+
.describe("Required for kind: \"portfolio\" — the terms to compare, e.g. your brand, your " +
|
|
487
|
+
"products and your competitors (1-25, duplicates ignored). Each is swept across " +
|
|
488
|
+
"platforms. Not used for other kinds; for a portfolio, term is its optional name."),
|
|
349
489
|
cadence: z.enum(CADENCES).optional().describe("How often to run. Defaults to daily."),
|
|
350
490
|
budgetCredits: z
|
|
351
491
|
.number()
|
|
@@ -494,6 +634,59 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
494
634
|
return failed("mention_trend failed", err);
|
|
495
635
|
}
|
|
496
636
|
});
|
|
637
|
+
server.registerTool("update_watch_portfolio", {
|
|
638
|
+
title: "Update Watch Portfolio",
|
|
639
|
+
description: "Add terms to a portfolio brand watch, or remove them, by watchId, with add and/or remove " +
|
|
640
|
+
"lists. No new confirmation: the per-run budget and cadence the user agreed to do not " +
|
|
641
|
+
"change, so a run still never costs more than that ceiling — but more terms split the same " +
|
|
642
|
+
"budget more ways, so the response says which networks a run will still search. If it " +
|
|
643
|
+
"names any in platformsSkipped, tell the user those networks are no longer searched and " +
|
|
644
|
+
"offer to remove a term or recreate the watch with a larger budget. A change the budget " +
|
|
645
|
+
"cannot cover at all is refused and nothing changes. No cost to call: no credits are spent.",
|
|
646
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
647
|
+
inputSchema: z
|
|
648
|
+
.object({
|
|
649
|
+
watchId: z.string().describe("The portfolio watch's id, from list_brand_watches."),
|
|
650
|
+
add: z.array(z.string()).optional().describe("Terms to add (duplicates ignored)."),
|
|
651
|
+
remove: z
|
|
652
|
+
.array(z.string())
|
|
653
|
+
.optional()
|
|
654
|
+
.describe("Terms to take out, matched without regard to case."),
|
|
655
|
+
})
|
|
656
|
+
.strict(),
|
|
657
|
+
outputSchema: OUTPUT_SCHEMAS.update_watch_portfolio,
|
|
658
|
+
}, async (args, extra) => {
|
|
659
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
660
|
+
try {
|
|
661
|
+
const result = toResult(await client.callTool("update_watch_portfolio", args));
|
|
662
|
+
const sc = (result.structuredContent ?? {});
|
|
663
|
+
if (!sc.updated)
|
|
664
|
+
return result;
|
|
665
|
+
// Both channels (#44, #51): a host that renders structuredContent drops
|
|
666
|
+
// the text block, and one that shows only text never sees the payload —
|
|
667
|
+
// and a network the split budget stopped reaching is the one thing
|
|
668
|
+
// here the user has to be told.
|
|
669
|
+
const skipped = Array.isArray(sc.platformsSkipped) ? sc.platformsSkipped : [];
|
|
670
|
+
const terms = Array.isArray(sc.terms) ? sc.terms : [];
|
|
671
|
+
const guidance = `The portfolio now watches ${terms.length} term${terms.length === 1 ? "" : "s"}: ` +
|
|
672
|
+
`${terms.join(", ")}. A run costs ${sc.costPerRun ?? "?"} credits, within the ` +
|
|
673
|
+
`${sc.budgetPerRun ?? "?"}-credit ceiling already agreed, and searches ` +
|
|
674
|
+
`${(Array.isArray(sc.platformsSearched) ? sc.platformsSearched : []).join(", ") || "no network"}.` +
|
|
675
|
+
(skipped.length
|
|
676
|
+
? ` Split this many ways, the budget no longer reaches ${skipped.join(", ")} — tell the ` +
|
|
677
|
+
"user those networks are not searched for any term now, and offer to remove a term " +
|
|
678
|
+
"or recreate the watch with a larger budget if they matter."
|
|
679
|
+
: "");
|
|
680
|
+
const payload = { guidance, ...sc };
|
|
681
|
+
return {
|
|
682
|
+
content: [{ type: "text", text: guidance }],
|
|
683
|
+
structuredContent: payload,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
catch (err) {
|
|
687
|
+
return failed("update_watch_portfolio failed", err);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
497
690
|
server.registerTool("stop_brand_watch", {
|
|
498
691
|
title: "Stop Brand Watch",
|
|
499
692
|
description: "Stop a scheduled brand-monitoring watch, by watchId or by term. Takes effect immediately: " +
|
|
@@ -474,6 +474,7 @@ export const LOADING_PLANS = {
|
|
|
474
474
|
create_brand_watch: { label: "Setting up the watch", kind: "text", n: 1, steps: [], free: true },
|
|
475
475
|
list_brand_watches: { label: "Reading your watches", kind: "list", n: 3, steps: [], free: true },
|
|
476
476
|
stop_brand_watch: { label: "Stopping the watch", kind: "text", n: 1, steps: [], free: true },
|
|
477
|
+
update_watch_portfolio: { label: "Updating the portfolio", kind: "text", n: 1, steps: [], free: true },
|
|
477
478
|
// ─── Own-account reads: nooticr's own stored rows, never an upstream call ───
|
|
478
479
|
list_own_apps: { label: "Reading your products", kind: "list", n: 3, steps: [], free: true },
|
|
479
480
|
get_content_plan: { label: "Reading the saved plan", kind: "text", n: 1, steps: [], free: true },
|
package/dist/shared/nooticr.js
CHANGED
|
@@ -45,14 +45,17 @@ export class NooticrClient {
|
|
|
45
45
|
tokenProvider;
|
|
46
46
|
/** Stable per-call key so a retried tool call is billed once. */
|
|
47
47
|
idempotencyKey;
|
|
48
|
-
|
|
48
|
+
/** Sent on every request — how a forwarded chat call is attributed. */
|
|
49
|
+
extraHeaders;
|
|
50
|
+
constructor(baseUrl, tokenProvider, idempotencyKey, extraHeaders = {}) {
|
|
49
51
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
50
52
|
this.tokenProvider = tokenProvider;
|
|
51
53
|
this.idempotencyKey = idempotencyKey;
|
|
54
|
+
this.extraHeaders = extraHeaders;
|
|
52
55
|
}
|
|
53
56
|
async request(method, path, opts = {}) {
|
|
54
57
|
const doRequest = async (accessToken) => {
|
|
55
|
-
const headers = {};
|
|
58
|
+
const headers = { ...this.extraHeaders };
|
|
56
59
|
if (opts.body !== undefined) {
|
|
57
60
|
headers["content-type"] = "application/json";
|
|
58
61
|
}
|
|
@@ -1165,9 +1165,11 @@ export const OUTPUT_SCHEMAS = {
|
|
|
1165
1165
|
confirmationToken: scalar().describe("Pass this back with confirm: true to actually create the watch."),
|
|
1166
1166
|
expiresInSeconds: scalar(),
|
|
1167
1167
|
quote: open({
|
|
1168
|
-
kind: scalar().describe("\"mentions\" or \"
|
|
1169
|
-
term: scalar().describe("The search term (mentions)
|
|
1170
|
-
platforms: listOf(z.string()).describe("Networks swept (mentions), or the creator's single platform (competitor)."),
|
|
1168
|
+
kind: scalar().describe("\"mentions\", \"competitor\", \"report\" or \"portfolio\"."),
|
|
1169
|
+
term: scalar().describe("The search term (mentions), the creator handle (competitor), or the watch's name (report, portfolio)."),
|
|
1170
|
+
platforms: listOf(z.string()).describe("Networks swept (mentions, portfolio), or the creator's single platform (competitor)."),
|
|
1171
|
+
job: anyObject().nullish().describe("Report only: the {tool, args} each run performs, as it will run."),
|
|
1172
|
+
terms: listOf(z.string()).describe("Portfolio only: the terms each run sweeps."),
|
|
1171
1173
|
cadence: scalar(),
|
|
1172
1174
|
cadenceMinutes: scalar(),
|
|
1173
1175
|
costPerRun: scalar(),
|
|
@@ -1183,11 +1185,13 @@ export const OUTPUT_SCHEMAS = {
|
|
|
1183
1185
|
rejected: scalar().describe("Set when confirm was true but the token didn't check out — nothing was created."),
|
|
1184
1186
|
alreadyWatching: scalar().describe("Set when this term already has an enabled watch — creating another would double-charge."),
|
|
1185
1187
|
watchId: scalar(),
|
|
1186
|
-
kind: scalar().describe("\"mentions\" or \"
|
|
1188
|
+
kind: scalar().describe("\"mentions\", \"competitor\", \"report\" or \"portfolio\"."),
|
|
1187
1189
|
term: scalar(),
|
|
1188
1190
|
platforms: listOf(z.string()),
|
|
1189
1191
|
competitorHandle: scalar().describe("Set only for a competitor watch."),
|
|
1190
1192
|
competitorPlatform: scalar().describe("Set only for a competitor watch."),
|
|
1193
|
+
job: anyObject().nullish().describe("Set only for a report watch."),
|
|
1194
|
+
terms: listOf(z.string()).describe("Set only for a portfolio watch."),
|
|
1191
1195
|
cadence: scalar(),
|
|
1192
1196
|
costPerRun: scalar(),
|
|
1193
1197
|
creditsPerDay: scalar(),
|
|
@@ -1219,6 +1223,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
1219
1223
|
found: scalar().describe("Everything that sweep saw."),
|
|
1220
1224
|
reported: scalar().describe("The subset that was new — a different question from found."),
|
|
1221
1225
|
perPlatform: anyObject().nullish().describe('{"tiktok":{"found":12,"reported":3},...}'),
|
|
1226
|
+
perTerm: anyObject().nullish().describe('Portfolio watches only: {"acme":{"found":9,"reported":2,"share":60},...}'),
|
|
1222
1227
|
medianViews: scalar().describe("Competitor watches only: the baseline that run measured."),
|
|
1223
1228
|
postsScored: scalar(),
|
|
1224
1229
|
costCredits: scalar(),
|
|
@@ -1228,6 +1233,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
1228
1233
|
term: scalar(),
|
|
1229
1234
|
competitorHandle: scalar(),
|
|
1230
1235
|
platforms: listOf(z.string()),
|
|
1236
|
+
terms: listOf(z.string()).describe("Portfolio watches only."),
|
|
1231
1237
|
windowDays: scalar(),
|
|
1232
1238
|
// Two numbers a caller cannot derive and will otherwise assume: how long
|
|
1233
1239
|
// anything is kept, and how long the watch has existed. A flat left edge
|
|
@@ -1250,11 +1256,13 @@ export const OUTPUT_SCHEMAS = {
|
|
|
1250
1256
|
list_brand_watches: open({
|
|
1251
1257
|
watches: listOf(open({
|
|
1252
1258
|
watchId: scalar(),
|
|
1253
|
-
kind: scalar().describe("\"mentions\" or \"
|
|
1259
|
+
kind: scalar().describe("\"mentions\", \"competitor\", \"report\" or \"portfolio\"."),
|
|
1254
1260
|
term: scalar(),
|
|
1255
1261
|
platforms: listOf(z.string()),
|
|
1256
1262
|
competitorHandle: scalar().describe("Set only for a competitor watch."),
|
|
1257
1263
|
competitorPlatform: scalar().describe("Set only for a competitor watch."),
|
|
1264
|
+
job: anyObject().nullish().describe("Set only for a report watch: the {tool, args} each run performs."),
|
|
1265
|
+
terms: listOf(z.string()).describe("Set only for a portfolio watch."),
|
|
1258
1266
|
cadence: scalar(),
|
|
1259
1267
|
costPerRun: scalar(),
|
|
1260
1268
|
budgetPerRun: scalar(),
|
|
@@ -1270,6 +1278,20 @@ export const OUTPUT_SCHEMAS = {
|
|
|
1270
1278
|
creditsPerDayAcrossAllWatches: scalar().describe("What every enabled watch together will cost per day if nothing changes."),
|
|
1271
1279
|
cost: scalar(),
|
|
1272
1280
|
}),
|
|
1281
|
+
update_watch_portfolio: open({
|
|
1282
|
+
guidance,
|
|
1283
|
+
updated: scalar(),
|
|
1284
|
+
watchId: scalar(),
|
|
1285
|
+
term: scalar(),
|
|
1286
|
+
terms: listOf(z.string()),
|
|
1287
|
+
platformsSearched: listOf(z.string()).describe("What a run will search now, for every term."),
|
|
1288
|
+
platformsSkipped: listOf(z.string()).describe("Networks the split budget no longer reaches — say so to the user."),
|
|
1289
|
+
costPerRun: scalar(),
|
|
1290
|
+
budgetPerRun: scalar(),
|
|
1291
|
+
cadence: scalar(),
|
|
1292
|
+
message: scalar(),
|
|
1293
|
+
cost: scalar(),
|
|
1294
|
+
}),
|
|
1273
1295
|
stop_brand_watch: open({
|
|
1274
1296
|
stopped: scalar(),
|
|
1275
1297
|
alreadyStopped: scalar(),
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -261,8 +261,8 @@ export const TOOL_DEFINITIONS = [
|
|
|
261
261
|
{
|
|
262
262
|
name: "create_brand_watch",
|
|
263
263
|
title: "Create Brand Watch",
|
|
264
|
-
description: "Run a sweep on a schedule and email what is new, instead of remembering to ask. kind: \"mentions\" (default) sweeps term across platforms; kind: \"competitor\" sweeps one creator's own posts (handle + platform) and reports only what beats their own recent median. Two calls by design: call it once with no confirmation to get back the cost per run and what it multiplies out to per day, then call it again with confirm: true and the confirmationToken to actually start it. The first call creates nothing and spends nothing. Bills 2 credits per network per run (5 for Xiaohongshu) for a mentions watch, or a flat 2 credits for a competitor watch, trimmed to fit budgetCredits. A run that finds nothing new (or, for a competitor watch, nothing above median) sends no mail. No cost to call.",
|
|
265
|
-
inputSchema: z.object({ kind: z.enum(["mentions", "competitor"]).optional().describe("\"mentions\" (default) or \"
|
|
264
|
+
description: "Run a sweep on a schedule and email what is new, instead of remembering to ask. kind: \"mentions\" (default) sweeps term across platforms; kind: \"competitor\" sweeps one creator's own posts (handle + platform) and reports only what beats their own recent median; kind: \"report\" runs job {tool, args} (niche_report or discover_social_posts) on the schedule at that tool's own price; kind: \"portfolio\" sweeps several terms across the same platforms under one budget and mails one digest with each term's share of voice. Two calls by design: call it once with no confirmation to get back the cost per run and what it multiplies out to per day, then call it again with confirm: true and the confirmationToken to actually start it. The first call creates nothing and spends nothing. Bills 2 credits per network per run (5 for Xiaohongshu) for a mentions watch, or a flat 2 credits for a competitor watch, trimmed to fit budgetCredits. A run that finds nothing new (or, for a competitor watch, nothing above median) sends no mail. No cost to call.",
|
|
265
|
+
inputSchema: z.object({ kind: z.enum(["mentions", "competitor", "report", "portfolio"]).optional().describe("\"mentions\" (default), \"competitor\", \"report\" or \"portfolio\"."), job: z.object({ tool: z.enum(["niche_report", "discover_social_posts"]), args: z.object({}).passthrough() }).optional().describe("kind: report only — the job each run performs."), terms: z.array(z.string()).optional().describe("kind: portfolio only — the terms to compare (1-25)."), term: z.string().optional().describe("kind: mentions only — what to watch for (max 120 chars)."), platforms: z.array(z.string()).optional().describe("kind: mentions only — networks to sweep (default: every searchable network)."), handle: z.string().optional().describe("kind: competitor only — the creator's handle."), platform: z.string().optional().describe("kind: competitor only — the platform that creator posts on."), cadence: z.enum(["hourly", "every_6_hours", "every_12_hours", "daily", "weekly"]).optional().describe("How often to run (default daily)."), budgetCredits: z.number().int().optional().describe("Hard per-run credit ceiling (default the full sweep's cost)."), deliverTo: z.string().optional().describe("Digest email (default the account's own)."), confirm: z.boolean().optional().describe("Set true only once the user has agreed to the quoted cost."), confirmationToken: z.string().optional().describe("The token the first call returned.") }).strict(),
|
|
266
266
|
},
|
|
267
267
|
{
|
|
268
268
|
name: "list_brand_watches",
|
|
@@ -288,6 +288,12 @@ export const TOOL_DEFINITIONS = [
|
|
|
288
288
|
description: "Stop a scheduled brand-monitoring watch, by watchId or by term. Takes effect immediately — the run that was due does not happen and nothing further is charged. Works even at a zero balance, since a user out of credits is exactly the user who needs to turn off what is spending them. No cost to call.",
|
|
289
289
|
inputSchema: z.object({ watchId: z.string().optional().describe("The watch's id, from list_brand_watches."), term: z.string().optional().describe("Alternative to watchId — the term it was watching.") }).strict(),
|
|
290
290
|
},
|
|
291
|
+
{
|
|
292
|
+
name: "update_watch_portfolio",
|
|
293
|
+
title: "Update Watch Portfolio",
|
|
294
|
+
description: "Add terms to a portfolio brand watch or remove them, by watchId. No new confirmation: the agreed per-run budget and cadence do not change, so a run never costs more credits than that ceiling — more terms split it more ways, and the response names any networks a run no longer reaches. A change the budget cannot cover is refused. No cost to call.",
|
|
295
|
+
inputSchema: z.object({ watchId: z.string().describe("The portfolio watch's id, from list_brand_watches."), add: z.array(z.string()).optional().describe("Terms to add."), remove: z.array(z.string()).optional().describe("Terms to take out.") }).strict(),
|
|
296
|
+
},
|
|
291
297
|
{
|
|
292
298
|
name: "list_own_apps",
|
|
293
299
|
title: "List Own Apps",
|
package/dist/shared/tools.js
CHANGED
|
@@ -30,7 +30,7 @@ import { registerCollabTools } from "./collab.js";
|
|
|
30
30
|
import { registerAmazonTools } from "./amazon.js";
|
|
31
31
|
import { registerMarketplaceTools } from "./marketplace.js";
|
|
32
32
|
/** Current MCP server version — bumped on every deploy for traceability. */
|
|
33
|
-
export const MCP_SERVER_VERSION = "1.26.
|
|
33
|
+
export const MCP_SERVER_VERSION = "1.26.49";
|
|
34
34
|
/** MCP Apps extension identifier */
|
|
35
35
|
const UI_EXTENSION = "io.modelcontextprotocol/ui";
|
|
36
36
|
/** MIME type for MCP Apps HTML resources */
|
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.49",
|
|
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": {
|